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}
23
24impl ScaleColorSteps {
25    /// Build from evenly-spaced gradient stops (`>= 1`) and a bin count.
26    pub fn new(aesthetic: Aesthetic, stops: Vec<(u8, u8, u8)>, n_bins: usize) -> Self {
27        ScaleColorSteps {
28            aesthetic,
29            stops: if stops.is_empty() {
30                vec![(50, 50, 200), (200, 50, 50)]
31            } else {
32                stops
33            },
34            n_bins: n_bins.max(1),
35            name: String::new(),
36            min: f64::INFINITY,
37            max: f64::NEG_INFINITY,
38            trained: false,
39        }
40    }
41
42    /// Two-colour low→high binned scale.
43    pub fn two(aesthetic: Aesthetic, low: (u8, u8, u8), high: (u8, u8, u8), n_bins: usize) -> Self {
44        Self::new(aesthetic, vec![low, high], n_bins)
45    }
46
47    fn interp(&self, f: f64) -> (u8, u8, u8) {
48        let f = f.clamp(0.0, 1.0);
49        if self.stops.len() == 1 {
50            return self.stops[0];
51        }
52        let segs = self.stops.len() - 1;
53        let pos = f * segs as f64;
54        let i = (pos.floor() as usize).min(segs - 1);
55        let t = pos - i as f64;
56        let (r0, g0, b0) = self.stops[i];
57        let (r1, g1, b1) = self.stops[i + 1];
58        let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
59        (lerp(r0, r1), lerp(g0, g1), lerp(b0, b1))
60    }
61
62    fn bin_index(&self, v: f64) -> usize {
63        if !self.trained || self.max <= self.min {
64            return 0;
65        }
66        let frac = (v - self.min) / (self.max - self.min);
67        ((frac * self.n_bins as f64).floor() as isize).clamp(0, self.n_bins as isize - 1) as usize
68    }
69
70    fn bin_color(&self, i: usize) -> (u8, u8, u8) {
71        // Colour at the bin centre.
72        self.interp((i as f64 + 0.5) / self.n_bins as f64)
73    }
74
75    fn bin_label(&self, i: usize) -> String {
76        if !self.trained || self.max <= self.min {
77            return format!("bin {i}");
78        }
79        let w = (self.max - self.min) / self.n_bins as f64;
80        let lo = self.min + i as f64 * w;
81        format!("[{:.2}, {:.2})", lo, lo + w)
82    }
83}
84
85impl Scale for ScaleColorSteps {
86    fn aesthetic(&self) -> Aesthetic {
87        self.aesthetic.clone()
88    }
89
90    fn train(&mut self, values: &[Value]) {
91        for v in values {
92            if let Some(f) = v.as_f64() {
93                if f.is_finite() {
94                    self.min = self.min.min(f);
95                    self.max = self.max.max(f);
96                    self.trained = true;
97                }
98            }
99        }
100    }
101
102    fn map(&self, _value: &Value) -> f64 {
103        0.0
104    }
105
106    fn breaks(&self) -> Vec<(f64, String)> {
107        (0..self.n_bins)
108            .map(|i| ((i as f64 + 0.5) / self.n_bins as f64, self.bin_label(i)))
109            .collect()
110    }
111
112    fn name(&self) -> &str {
113        &self.name
114    }
115
116    fn set_name(&mut self, name: &str) {
117        self.name = name.to_string();
118    }
119
120    fn is_discrete(&self) -> bool {
121        true
122    }
123
124    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
125        if let Some(f) = value.as_f64() {
126            return Some(self.bin_color(self.bin_index(f)));
127        }
128        // Legend passes the bin label back as a string — match it to a bin.
129        if let Value::Str(s) = value {
130            for i in 0..self.n_bins {
131                if &self.bin_label(i) == s {
132                    return Some(self.bin_color(i));
133                }
134            }
135        }
136        None
137    }
138
139    fn domain(&self) -> Option<(f64, f64)> {
140        if self.trained {
141            Some((self.min, self.max))
142        } else {
143            None
144        }
145    }
146
147    fn clone_box(&self) -> Box<dyn Scale> {
148        Box::new(self.clone())
149    }
150
151    fn reset_training(&mut self) {
152        self.min = f64::INFINITY;
153        self.max = f64::NEG_INFINITY;
154        self.trained = false;
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn trained(n: usize) -> ScaleColorSteps {
163        let mut s = ScaleColorSteps::two(Aesthetic::Color, (0, 0, 0), (255, 255, 255), n);
164        s.train(&[Value::Float(0.0), Value::Float(100.0)]);
165        s
166    }
167
168    #[test]
169    fn bins_map_to_distinct_colors() {
170        let s = trained(4);
171        let low = s.map_to_color(&Value::Float(5.0)).unwrap();
172        let high = s.map_to_color(&Value::Float(95.0)).unwrap();
173        assert_ne!(low, high);
174        // Endpoints clamp within range.
175        assert_eq!(
176            s.map_to_color(&Value::Float(-10.0)),
177            s.map_to_color(&Value::Float(0.0))
178        );
179    }
180
181    #[test]
182    fn same_bin_same_color() {
183        let s = trained(4); // bins of width 25
184        let a = s.map_to_color(&Value::Float(1.0)).unwrap();
185        let b = s.map_to_color(&Value::Float(24.0)).unwrap();
186        assert_eq!(a, b, "values in the same bin share a colour");
187    }
188
189    #[test]
190    fn legend_label_roundtrips_to_bin_color() {
191        let s = trained(5);
192        let breaks = s.breaks();
193        assert_eq!(breaks.len(), 5);
194        // Each break label must map (as a string) to the same colour as the bin.
195        for (i, (_, label)) in breaks.iter().enumerate() {
196            let via_label = s.map_to_color(&Value::Str(label.clone())).unwrap();
197            assert_eq!(via_label, s.bin_color(i));
198        }
199    }
200}