Skip to main content

ggplot_rs/scale/
binned.rs

1//! Binned fill/colour scale with *explicit* class breaks (R's
2//! `scale_fill_steps`/`scale_fill_fermenter` generalised to arbitrary,
3//! caller-supplied edges — ggplot2's `scale_fill_binned` with `breaks` set
4//! "manually"), backing a `::CLASSES` choropleth (sipemu/ggplot-rs#180).
5//!
6//! The break *maths* — quantile/Fisher-Jenks/equal-width/custom edges —
7//! stays the caller's job (anofox-visualization's `map/classes.rs`); this
8//! scale only bins against the edges it's given, tallies how many trained
9//! values fall in each bin, and draws a stepped legend with those counts
10//! plus a "no data" key for `Value::Na` (ggplot2's `na.value`).
11
12use std::collections::HashSet;
13
14use crate::aes::Aesthetic;
15use crate::data::Value;
16use crate::format::format_value;
17
18use super::Scale;
19
20/// A coarse, order-sensitive fingerprint of a `train()` batch (FNV-1a over
21/// each value's length/discriminant/bits). See [`ScaleFillBinned::seen`]'s
22/// doc for why this exists.
23fn signature(values: &[Value]) -> u64 {
24    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
25    let mut mix = |x: u64| {
26        h ^= x;
27        h = h.wrapping_mul(0x0000_0100_0000_01b3);
28    };
29    mix(values.len() as u64);
30    for v in values {
31        match v {
32            Value::Na => mix(1),
33            _ => match v.as_f64() {
34                Some(f) => {
35                    mix(2);
36                    mix(f.to_bits());
37                }
38                None => mix(3),
39            },
40        }
41    }
42    h
43}
44
45/// Binned fill/colour scale: `n` classes from `n + 1` increasing edges,
46/// each with its own colour. See the [module docs](self) for how it
47/// relates to `scale_fill_steps`/`::CLASSES`.
48#[derive(Clone, Debug)]
49pub struct ScaleFillBinned {
50    aesthetic: Aesthetic,
51    /// `n + 1` increasing edges (`colors.len() == n`).
52    breaks: Vec<f64>,
53    colors: Vec<(u8, u8, u8)>,
54    /// Trained-value count per bin, same indices as `colors`.
55    counts: Vec<usize>,
56    /// Trained `Value::Na` count.
57    na_count: usize,
58    /// Fingerprints of every batch already folded into `counts`/`na_count`
59    /// (see [`signature`]). `Scale::train` can run more than once over the
60    /// *same* layer data — `build.rs`'s own "final scale training pass"
61    /// re-trains every layer's already-built data on top of the per-layer
62    /// pass `build_layer` just did, and faceting clones a scale set and
63    /// retrains it again per panel — which every other scale in this crate
64    /// tolerates for free because re-expanding a min/max domain with data
65    /// it has already seen is a no-op. A running *count* isn't naturally
66    /// idempotent that way, so this scale has to recognise "I've already
67    /// counted this exact batch" itself and skip it, rather than double
68    /// (or triple) class counts and the no-data count.
69    seen: HashSet<u64>,
70    /// ggplot2's `na.value` — colour for `Value::Na` and for the legend's
71    /// "no data" key. Default grey50 `(127, 127, 127)`.
72    na_color: (u8, u8, u8),
73    na_label: String,
74    name: String,
75    corrected: bool,
76}
77
78/// At least two increasing, finite edges: non-finite values dropped,
79/// sorted, exact duplicates collapsed; fewer than two left falls back to
80/// `[0.0, 1.0]` (a single degenerate bin) rather than panicking.
81fn sorted_edges(breaks: Vec<f64>) -> (Vec<f64>, bool) {
82    let mut v: Vec<f64> = breaks.into_iter().filter(|f| f.is_finite()).collect();
83    v.sort_by(f64::total_cmp);
84    v.dedup();
85    if v.len() >= 2 {
86        (v, false)
87    } else {
88        (vec![0.0, 1.0], true)
89    }
90}
91
92/// Linear RGB interpolation at `t` in `[0, 1]`.
93fn lerp_color(low: (u8, u8, u8), high: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
94    let t = t.clamp(0.0, 1.0);
95    let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
96    (
97        lerp(low.0, high.0),
98        lerp(low.1, high.1),
99        lerp(low.2, high.2),
100    )
101}
102
103/// `n` colours evenly interpolated from `low` to `high` (`n == 1` picks the
104/// midpoint).
105fn default_colors(n: usize, low: (u8, u8, u8), high: (u8, u8, u8)) -> Vec<(u8, u8, u8)> {
106    (0..n)
107        .map(|i| {
108            let t = if n <= 1 {
109                0.5
110            } else {
111                i as f64 / (n - 1) as f64
112            };
113            lerp_color(low, high, t)
114        })
115        .collect()
116}
117
118/// This crate's default two-colour ramp (matches [`super::steps::ScaleColorSteps`]'s).
119const DEFAULT_LOW: (u8, u8, u8) = (50, 50, 200);
120const DEFAULT_HIGH: (u8, u8, u8) = (200, 50, 50);
121
122impl ScaleFillBinned {
123    /// `n + 1` increasing `breaks` with an explicit colour per bin
124    /// (`colors.len()` should be `breaks.len() - 1`; a mismatch falls back
125    /// to the default two-colour ramp and is reported by
126    /// [`validate`](Scale::validate)).
127    pub fn new(aesthetic: Aesthetic, breaks: Vec<f64>, colors: Vec<(u8, u8, u8)>) -> Self {
128        let (breaks, edges_corrected) = sorted_edges(breaks);
129        let n = breaks.len() - 1;
130        let (colors, colors_corrected) = if colors.len() == n {
131            (colors, false)
132        } else {
133            (default_colors(n, DEFAULT_LOW, DEFAULT_HIGH), true)
134        };
135        ScaleFillBinned {
136            aesthetic,
137            breaks,
138            colors,
139            counts: vec![0; n],
140            na_count: 0,
141            seen: HashSet::new(),
142            na_color: (127, 127, 127),
143            na_label: "no data".to_string(),
144            name: String::new(),
145            corrected: edges_corrected || colors_corrected,
146        }
147    }
148
149    /// `n + 1` increasing `breaks`, coloured by interpolating `low`→`high`
150    /// across the `n` bins (R's `scale_fill_steps(breaks = ...)`).
151    pub fn two(
152        aesthetic: Aesthetic,
153        breaks: Vec<f64>,
154        low: (u8, u8, u8),
155        high: (u8, u8, u8),
156    ) -> Self {
157        let (breaks, corrected) = sorted_edges(breaks);
158        let n = breaks.len() - 1;
159        ScaleFillBinned {
160            aesthetic,
161            colors: default_colors(n, low, high),
162            counts: vec![0; n],
163            breaks,
164            na_count: 0,
165            seen: HashSet::new(),
166            na_color: (127, 127, 127),
167            na_label: "no data".to_string(),
168            name: String::new(),
169            corrected,
170        }
171    }
172
173    /// Colour for `Value::Na` and the legend's "no data" key (ggplot2's
174    /// `na.value`). Default grey50 `(127, 127, 127)`.
175    pub fn na_color(mut self, color: (u8, u8, u8)) -> Self {
176        self.na_color = color;
177        self
178    }
179
180    /// Label for the "no data" legend key. Default `"no data"`.
181    pub fn na_label(mut self, label: &str) -> Self {
182        self.na_label = label.to_string();
183        self
184    }
185
186    /// The half-open `[breaks[i], breaks[i+1])` bin index for `v`
187    /// (clamped into range first; the last bin is closed at its upper
188    /// edge, so the maximum trained/data value lands in it rather than
189    /// falling just outside every bin).
190    fn bin_index(&self, v: f64) -> usize {
191        let n = self.colors.len();
192        let v = v.clamp(self.breaks[0], self.breaks[n]);
193        for (i, w) in self.breaks[1..n].iter().enumerate() {
194            if v < *w {
195                return i;
196            }
197        }
198        n - 1
199    }
200
201    /// `"{lo}–{hi} ({count})"` for bin `i`.
202    fn class_label(&self, i: usize) -> String {
203        format!(
204            "{}–{} ({})",
205            format_value(&Value::Float(self.breaks[i])),
206            format_value(&Value::Float(self.breaks[i + 1])),
207            self.counts[i]
208        )
209    }
210
211    /// `"{na_label} ({na_count})"`.
212    fn na_label_with_count(&self) -> String {
213        format!("{} ({})", self.na_label, self.na_count)
214    }
215}
216
217impl Scale for ScaleFillBinned {
218    fn aesthetic(&self) -> Aesthetic {
219        self.aesthetic.clone()
220    }
221
222    fn train(&mut self, values: &[Value]) {
223        // See `seen`'s doc: the pipeline can submit the exact same layer's
224        // data to `train` more than once, which every other scale's
225        // min/max expansion tolerates for free but a running count can't.
226        if !self.seen.insert(signature(values)) {
227            return;
228        }
229        for v in values {
230            match v {
231                Value::Na => self.na_count += 1,
232                _ => {
233                    if let Some(f) = v.as_f64() {
234                        if f.is_finite() {
235                            let i = self.bin_index(f);
236                            self.counts[i] += 1;
237                        }
238                    }
239                }
240            }
241        }
242    }
243
244    /// Not used for position — like [`super::steps::ScaleColorSteps`], the
245    /// legend looks a value's colour up by its break label instead.
246    fn map(&self, _value: &Value) -> f64 {
247        0.0
248    }
249
250    fn breaks(&self) -> Vec<(f64, String)> {
251        let n = self.colors.len();
252        let (lo0, hi_n) = (self.breaks[0], self.breaks[n]);
253        let span = (hi_n - lo0).max(f64::EPSILON);
254        let mut out: Vec<(f64, String)> = (0..n)
255            .map(|i| {
256                let center = (self.breaks[i] + self.breaks[i + 1]) / 2.0;
257                ((center - lo0) / span, self.class_label(i))
258            })
259            .collect();
260        if self.na_count > 0 {
261            out.push((1.0, self.na_label_with_count()));
262        }
263        out
264    }
265
266    fn name(&self) -> &str {
267        &self.name
268    }
269
270    fn set_name(&mut self, name: &str) {
271        self.name = name.to_string();
272    }
273
274    fn is_discrete(&self) -> bool {
275        true
276    }
277
278    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
279        if matches!(value, Value::Na) {
280            return Some(self.na_color);
281        }
282        if let Some(f) = value.as_f64() {
283            if f.is_finite() {
284                return Some(self.colors[self.bin_index(f)]);
285            }
286        }
287        if let Value::Str(s) = value {
288            if s == &self.na_label_with_count() {
289                return Some(self.na_color);
290            }
291            for i in 0..self.colors.len() {
292                if self.class_label(i) == *s {
293                    return Some(self.colors[i]);
294                }
295            }
296        }
297        None
298    }
299
300    fn domain(&self) -> Option<(f64, f64)> {
301        let n = self.colors.len();
302        Some((self.breaks[0], self.breaks[n]))
303    }
304
305    fn validate(&self, warnings: &mut Vec<String>) {
306        if self.corrected {
307            warnings.push(
308                "scale_fill_binned: breaks need >= 2 increasing finite edges, with one colour \
309                 per bin — falling back to a default range/palette"
310                    .to_string(),
311            );
312        }
313    }
314
315    fn clone_box(&self) -> Box<dyn Scale> {
316        Box::new(self.clone())
317    }
318
319    fn reset_training(&mut self) {
320        self.counts.iter_mut().for_each(|c| *c = 0);
321        self.na_count = 0;
322        self.seen.clear();
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn scale() -> ScaleFillBinned {
331        ScaleFillBinned::new(
332            Aesthetic::Fill,
333            vec![0.0, 10.0, 50.0, 100.0],
334            vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
335        )
336    }
337
338    #[test]
339    fn bins_are_half_open_except_the_last() {
340        let s = scale();
341        assert_eq!(s.bin_index(0.0), 0);
342        assert_eq!(s.bin_index(9.999), 0);
343        assert_eq!(s.bin_index(10.0), 1); // right edge moves to the next bin...
344        assert_eq!(s.bin_index(49.999), 1);
345        assert_eq!(s.bin_index(50.0), 2);
346        assert_eq!(s.bin_index(100.0), 2); // ...except the very last, which is closed
347        assert_eq!(
348            s.bin_index(1000.0),
349            2,
350            "out-of-range clamps to the last bin"
351        );
352        assert_eq!(
353            s.bin_index(-50.0),
354            0,
355            "out-of-range clamps to the first bin"
356        );
357    }
358
359    #[test]
360    fn train_tallies_counts_and_na_separately() {
361        let mut s = scale();
362        s.train(&[
363            Value::Float(1.0),
364            Value::Float(5.0),
365            Value::Float(20.0),
366            Value::Float(99.0),
367            Value::Float(100.0),
368            Value::Na,
369            Value::Na,
370        ]);
371        assert_eq!(s.counts, vec![2, 1, 2]);
372        assert_eq!(s.na_count, 2);
373    }
374
375    /// `build.rs`'s own "final scale training pass" (and, for a faceted
376    /// plot, `compute_panel_scales`) re-submits a layer's already-trained
377    /// data to `Scale::train` a second time — every other scale tolerates
378    /// that for free (re-expanding a min/max domain with data it's already
379    /// seen is a no-op); a running count must recognise the repeat itself.
380    #[test]
381    fn training_the_same_batch_twice_does_not_double_count() {
382        let batch = [Value::Float(1.0), Value::Float(20.0), Value::Na];
383        let mut s = scale();
384        s.train(&batch);
385        s.train(&batch); // the pipeline's redundant re-training pass
386        assert_eq!(s.counts, vec![1, 1, 0]);
387        assert_eq!(s.na_count, 1);
388        // A genuinely different batch afterward still counts normally.
389        s.train(&[Value::Float(60.0)]);
390        assert_eq!(s.counts, vec![1, 1, 1]);
391    }
392
393    #[test]
394    fn breaks_label_each_class_with_its_count_and_na_with_its_own() {
395        let mut s = scale();
396        s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
397        let breaks = s.breaks();
398        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
399        assert_eq!(
400            labels,
401            vec!["0–10 (1)", "10–50 (1)", "50–100 (0)", "no data (1)"]
402        );
403    }
404
405    #[test]
406    fn no_na_key_when_nothing_is_missing() {
407        let mut s = scale();
408        s.train(&[Value::Float(1.0)]);
409        assert_eq!(s.breaks().len(), 3, "no trailing no-data entry");
410    }
411
412    #[test]
413    fn map_to_color_matches_the_bin_and_roundtrips_legend_labels() {
414        let mut s = scale();
415        s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
416        assert_eq!(s.map_to_color(&Value::Float(5.0)), Some((0, 0, 255)));
417        assert_eq!(s.map_to_color(&Value::Float(20.0)), Some((0, 255, 0)));
418        assert_eq!(s.map_to_color(&Value::Float(75.0)), Some((255, 0, 0)));
419        assert_eq!(s.map_to_color(&Value::Na), Some((127, 127, 127)));
420        for (_, label) in s.breaks() {
421            let via_label = s.map_to_color(&Value::Str(label.clone()));
422            assert!(via_label.is_some(), "{label} should round-trip to a colour");
423        }
424    }
425
426    #[test]
427    fn na_color_and_label_are_configurable() {
428        let mut s = scale().na_color((10, 20, 30)).na_label("missing");
429        s.train(&[Value::Na]);
430        assert_eq!(s.map_to_color(&Value::Na), Some((10, 20, 30)));
431        assert!(s.breaks().iter().any(|(_, l)| l == "missing (1)"));
432        assert_eq!(
433            s.map_to_color(&Value::Str("missing (1)".to_string())),
434            Some((10, 20, 30))
435        );
436    }
437
438    #[test]
439    fn two_interpolates_colors_across_bins() {
440        let s = ScaleFillBinned::two(
441            Aesthetic::Fill,
442            vec![0.0, 25.0, 50.0, 75.0, 100.0],
443            (0, 0, 0),
444            (255, 255, 255),
445        );
446        assert_eq!(s.colors[0], (0, 0, 0));
447        assert_eq!(s.colors[3], (255, 255, 255));
448        assert!(s.colors[1].0 < s.colors[2].0);
449    }
450
451    #[test]
452    fn mismatched_colors_fall_back_and_warn() {
453        let s = ScaleFillBinned::new(
454            Aesthetic::Fill,
455            vec![0.0, 10.0, 20.0],
456            vec![(0, 0, 0)], // 2 bins need 2 colours, only 1 given
457        );
458        assert_eq!(s.colors.len(), 2);
459        let mut warnings = Vec::new();
460        s.validate(&mut warnings);
461        assert_eq!(warnings.len(), 1);
462    }
463
464    #[test]
465    fn too_few_breaks_falls_back_to_a_single_bin_rather_than_panicking() {
466        let s = ScaleFillBinned::new(Aesthetic::Fill, vec![5.0], vec![]);
467        assert_eq!(s.breaks.len(), 2);
468        assert_eq!(s.colors.len(), 1);
469        let mut warnings = Vec::new();
470        s.validate(&mut warnings);
471        assert_eq!(warnings.len(), 1);
472    }
473
474    #[test]
475    fn domain_spans_the_outer_edges() {
476        let s = scale();
477        assert_eq!(s.domain(), Some((0.0, 100.0)));
478    }
479
480    #[test]
481    fn reset_training_clears_counts_but_keeps_breaks_and_colors() {
482        let mut s = scale();
483        s.train(&[Value::Float(1.0), Value::Na]);
484        s.reset_training();
485        assert_eq!(s.counts, vec![0, 0, 0]);
486        assert_eq!(s.na_count, 0);
487        assert_eq!(s.breaks, vec![0.0, 10.0, 50.0, 100.0]);
488    }
489}