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