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/// `ScaleFillBinned::na_key` (sipemu/ggplot-rs#269): whether the legend's
22/// "no data" key is shown.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
24pub enum NaKey {
25    /// Today's behaviour (unchanged default): the key appears only when
26    /// at least one trained value was `Value::Na`.
27    #[default]
28    Auto,
29    /// Always show the key, with its count (`"no data (0)"` when nothing
30    /// is missing) — a plot rendered once per animation frame needs a
31    /// legend that doesn't grow or shrink as the missing count changes
32    /// from frame to frame.
33    Always,
34    /// Never show the key, even with `Value::Na` trained — the caller
35    /// draws its own "no data" explanation elsewhere.
36    Never,
37}
38
39/// A coarse, order-sensitive fingerprint of a `train()` batch (FNV-1a over
40/// each value's length/discriminant/bits). See [`ScaleFillBinned::seen`]'s
41/// doc for why this exists.
42fn signature(values: &[Value]) -> u64 {
43    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
44    let mut mix = |x: u64| {
45        h ^= x;
46        h = h.wrapping_mul(0x0000_0100_0000_01b3);
47    };
48    mix(values.len() as u64);
49    for v in values {
50        match v {
51            Value::Na => mix(1),
52            _ => match v.as_f64() {
53                Some(f) => {
54                    mix(2);
55                    mix(f.to_bits());
56                }
57                None => mix(3),
58            },
59        }
60    }
61    h
62}
63
64/// Binned fill/colour scale: `n` classes from `n + 1` increasing edges,
65/// each with its own colour. See the [module docs](self) for how it
66/// relates to `scale_fill_steps`/`::CLASSES`.
67#[derive(Clone, Debug)]
68pub struct ScaleFillBinned {
69    aesthetic: Aesthetic,
70    /// `n + 1` increasing edges (`colors.len() == n`).
71    breaks: Vec<f64>,
72    colors: Vec<(u8, u8, u8)>,
73    /// Trained-value count per bin, same indices as `colors`.
74    counts: Vec<usize>,
75    /// Trained `Value::Na` count.
76    na_count: usize,
77    /// Fingerprints of every batch already folded into `counts`/`na_count`
78    /// (see [`signature`]). `Scale::train` can run more than once over the
79    /// *same* layer data — `build.rs`'s own "final scale training pass"
80    /// re-trains every layer's already-built data on top of the per-layer
81    /// pass `build_layer` just did, and faceting clones a scale set and
82    /// retrains it again per panel — which every other scale in this crate
83    /// tolerates for free because re-expanding a min/max domain with data
84    /// it has already seen is a no-op. A running *count* isn't naturally
85    /// idempotent that way, so this scale has to recognise "I've already
86    /// counted this exact batch" itself and skip it, rather than double
87    /// (or triple) class counts and the no-data count.
88    seen: HashSet<u64>,
89    /// ggplot2's `na.value` — colour for `Value::Na` and for the legend's
90    /// "no data" key. Default grey50 `(127, 127, 127)`. Overridden by
91    /// `na_pattern` when that's set (#206).
92    na_color: (u8, u8, u8),
93    /// A pattern fill (a thin hatch, by default) for `Value::Na` and the
94    /// legend's "no data" key, drawn instead of the flat `na_color` by
95    /// `geom_sf`/`geom_polygon`/`geom_rect`/`geom_tile` — a classed
96    /// choropleth's no-data cells then read as "we have no reading here"
97    /// rather than a greyed-out *value* easily mistaken for one (#206).
98    /// `None` (the default): `na_color` draws flat, as before.
99    na_pattern: Option<FillPattern>,
100    na_label: String,
101    /// `na_key` (#269). Default `Auto` — today's `na_count > 0` rule.
102    na_key: NaKey,
103    /// `count_reserve` (#271) — layout-only digit-width reservation for
104    /// the legend's auto-wrap sizing pass; the drawn label is unaffected.
105    /// `None` (the default): unchanged, `breaks_for_layout` falls back to
106    /// `breaks()`.
107    count_reserve: Option<usize>,
108    name: String,
109    corrected: bool,
110}
111
112/// At least two increasing, finite edges: non-finite values dropped and
113/// sorted; fewer than two left falls back to `[0.0, 1.0]` (a single
114/// degenerate bin) rather than panicking.
115///
116/// Equal adjacent edges are kept rather than collapsed (sipemu/ggplot-rs#240):
117/// a quantile split (or any other caller-supplied break set) can legitimately
118/// repeat an edge, e.g. `[35.0, 150.0, 150.0]` for a class whose upper bin
119/// holds only the maximum. Deduping such edges away used to merge two
120/// classes into one and change the caller's requested class count; keeping
121/// them instead produces a *zero-width* bin, which [`bin_index`] and
122/// [`ScaleFillBinned::class_label`] give dedicated handling.
123fn sorted_edges(breaks: Vec<f64>) -> (Vec<f64>, bool) {
124    let mut v: Vec<f64> = breaks.into_iter().filter(|f| f.is_finite()).collect();
125    v.sort_by(f64::total_cmp);
126    if v.len() >= 2 {
127        (v, false)
128    } else {
129        (vec![0.0, 1.0], true)
130    }
131}
132
133/// Linear RGB interpolation at `t` in `[0, 1]`.
134fn lerp_color(low: (u8, u8, u8), high: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
135    let t = t.clamp(0.0, 1.0);
136    let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
137    (
138        lerp(low.0, high.0),
139        lerp(low.1, high.1),
140        lerp(low.2, high.2),
141    )
142}
143
144/// `n` colours evenly interpolated from `low` to `high` (`n == 1` picks the
145/// midpoint).
146fn default_colors(n: usize, low: (u8, u8, u8), high: (u8, u8, u8)) -> Vec<(u8, u8, u8)> {
147    (0..n)
148        .map(|i| {
149            let t = if n <= 1 {
150                0.5
151            } else {
152                i as f64 / (n - 1) as f64
153            };
154            lerp_color(low, high, t)
155        })
156        .collect()
157}
158
159/// This crate's default two-colour ramp (matches [`super::steps::ScaleColorSteps`]'s).
160const DEFAULT_LOW: (u8, u8, u8) = (50, 50, 200);
161const DEFAULT_HIGH: (u8, u8, u8) = (200, 50, 50);
162
163impl ScaleFillBinned {
164    /// `n + 1` increasing `breaks` with an explicit colour per bin
165    /// (`colors.len()` should be `breaks.len() - 1`; a mismatch falls back
166    /// to the default two-colour ramp and is reported by
167    /// [`validate`](Scale::validate)).
168    pub fn new(aesthetic: Aesthetic, breaks: Vec<f64>, colors: Vec<(u8, u8, u8)>) -> Self {
169        let (breaks, edges_corrected) = sorted_edges(breaks);
170        let n = breaks.len() - 1;
171        let (colors, colors_corrected) = if colors.len() == n {
172            (colors, false)
173        } else {
174            (default_colors(n, DEFAULT_LOW, DEFAULT_HIGH), true)
175        };
176        ScaleFillBinned {
177            aesthetic,
178            breaks,
179            colors,
180            counts: vec![0; n],
181            na_count: 0,
182            seen: HashSet::new(),
183            na_color: (127, 127, 127),
184            na_pattern: None,
185            na_label: "no data".to_string(),
186            na_key: NaKey::Auto,
187            count_reserve: None,
188            name: String::new(),
189            corrected: edges_corrected || colors_corrected,
190        }
191    }
192
193    /// `n + 1` increasing `breaks`, coloured by interpolating `low`→`high`
194    /// across the `n` bins (R's `scale_fill_steps(breaks = ...)`).
195    pub fn two(
196        aesthetic: Aesthetic,
197        breaks: Vec<f64>,
198        low: (u8, u8, u8),
199        high: (u8, u8, u8),
200    ) -> Self {
201        let (breaks, corrected) = sorted_edges(breaks);
202        let n = breaks.len() - 1;
203        ScaleFillBinned {
204            aesthetic,
205            colors: default_colors(n, low, high),
206            counts: vec![0; n],
207            breaks,
208            na_count: 0,
209            seen: HashSet::new(),
210            na_color: (127, 127, 127),
211            na_pattern: None,
212            na_label: "no data".to_string(),
213            na_key: NaKey::Auto,
214            count_reserve: None,
215            name: String::new(),
216            corrected,
217        }
218    }
219
220    /// Colour for `Value::Na` and the legend's "no data" key (ggplot2's
221    /// `na.value`). Default grey50 `(127, 127, 127)`. Overridden for
222    /// drawing (not for the legend count) by [`na_pattern`](Self::na_pattern)
223    /// when that's also set.
224    pub fn na_color(mut self, color: (u8, u8, u8)) -> Self {
225        self.na_color = color;
226        self
227    }
228
229    /// A pattern fill for `Value::Na` and the legend's "no data" key,
230    /// drawn instead of the flat [`na_color`](Self::na_color) — a classed
231    /// choropleth's no-data cells read as "no reading", not a muted
232    /// *value* (#206). `FillPattern::stripe(fg, bg)` is a reasonable
233    /// default hatch; `bg` is what a pattern-less backend (`plotters`)
234    /// falls back to.
235    pub fn na_pattern(mut self, pattern: FillPattern) -> Self {
236        self.na_pattern = Some(pattern);
237        self
238    }
239
240    /// Label for the "no data" legend key. Default `"no data"`.
241    pub fn na_label(mut self, label: &str) -> Self {
242        self.na_label = label.to_string();
243        self
244    }
245
246    /// Whether the "no data" legend key is shown (#269). Default
247    /// [`NaKey::Auto`] (today's behaviour: shown only when at least one
248    /// trained value was `Value::Na`). `NaKey::Always` keeps the key
249    /// (with its count, down to `"no data (0)"`) even when nothing is
250    /// missing — a plot rendered once per animation frame needs a
251    /// legend whose key count doesn't change (and so doesn't resize)
252    /// just because one particular frame happens to have no missing
253    /// values. `NaKey::Never` hides it even with `Value::Na` trained.
254    pub fn na_key(mut self, show: NaKey) -> Self {
255        self.na_key = show;
256        self
257    }
258
259    /// Lay the legend out — for auto-wrap sizing only, never for what's
260    /// drawn — as if every class's trained count had `max_count`'s digit
261    /// width (#271). Binned legend labels carry a count
262    /// (`"10–50 (12)"`); across an animation's frames the same scale's
263    /// counts change digit width while its breaks/colours stay fixed,
264    /// which can change the legend's measured pitch enough to flip an
265    /// auto-wrapped (horizontal) legend's row count and move the panel.
266    /// `max_count` should be the largest count any frame can produce
267    /// (e.g. the full dataset's count per class). Default `None`: off,
268    /// byte-identical to before this existed.
269    pub fn count_reserve(mut self, max_count: usize) -> Self {
270        self.count_reserve = Some(max_count);
271        self
272    }
273
274    /// Pad `label`'s trailing `" (count)"` so its digit run is at least
275    /// `min_digits` wide (zero-padded), for [`breaks_for_layout`]'s
276    /// `count_reserve` sizing pass — never for what's drawn. A label that
277    /// doesn't end in `(<digits>)` (shouldn't happen for this scale's own
278    /// breaks, but keeps this generic) is returned unchanged.
279    ///
280    /// [`breaks_for_layout`]: Scale::breaks_for_layout
281    fn pad_trailing_count(label: &str, min_digits: usize) -> String {
282        if label.ends_with(')') {
283            if let Some(open) = label.rfind('(') {
284                let inner = &label[open + 1..label.len() - 1];
285                if !inner.is_empty() && inner.chars().all(|c| c.is_ascii_digit()) {
286                    let digits = min_digits.max(inner.len());
287                    return format!("{}({inner:0>digits$})", &label[..open]);
288                }
289            }
290        }
291        label.to_string()
292    }
293
294    /// The bin index for `v` (clamped into range first).
295    ///
296    /// An ordinary bin `i` is half-open, `[breaks[i], breaks[i+1])`; the
297    /// last bin is closed at its upper edge too, so the maximum
298    /// trained/data value lands in it rather than falling just outside
299    /// every bin. A **zero-width** bin (`breaks[i] == breaks[i+1]`, e.g.
300    /// from a quantile split whose top class holds only the maximum,
301    /// sipemu/ggplot-rs#240) is closed at *both* ends instead, so it
302    /// captures values equal to its single shared edge rather than losing
303    /// them to whichever neighbour the half-open rule would otherwise hand
304    /// them to. Bins are scanned low to high and the first match wins, so
305    /// when several consecutive bins share the same point the lowest-index
306    /// one claims it (ggplot2's `cut(..., right = TRUE)` convention, with
307    /// the first bin additionally closed on the left so the global minimum
308    /// is never dropped).
309    fn bin_index(&self, v: f64) -> usize {
310        let n = self.colors.len();
311        let v = v.clamp(self.breaks[0], self.breaks[n]);
312        for i in 0..n {
313            let (lo, hi) = (self.breaks[i], self.breaks[i + 1]);
314            let hit = if i == n - 1 {
315                v >= lo && v <= hi // last bin: closed at both ends
316            } else if lo == hi {
317                v == lo // zero-width bin: closed at both ends
318            } else {
319                v >= lo && v < hi // ordinary bin: half-open
320            };
321            if hit {
322                return i;
323            }
324        }
325        n - 1
326    }
327
328    /// `"{lo}–{hi} ({count})"` for bin `i`, or just `"{v} ({count})"` when
329    /// `i` is a zero-width bin (`breaks[i] == breaks[i + 1]`, #240) — a
330    /// range like "150–150" would misstate a bin that in fact holds only
331    /// the single value 150.
332    fn class_label(&self, i: usize) -> String {
333        let (lo, hi) = (self.breaks[i], self.breaks[i + 1]);
334        if lo == hi {
335            format!("{} ({})", format_value(&Value::Float(lo)), self.counts[i])
336        } else {
337            format!(
338                "{}–{} ({})",
339                format_value(&Value::Float(lo)),
340                format_value(&Value::Float(hi)),
341                self.counts[i]
342            )
343        }
344    }
345
346    /// `"{na_label} ({na_count})"`.
347    fn na_label_with_count(&self) -> String {
348        format!("{} ({})", self.na_label, self.na_count)
349    }
350
351    /// Whether `breaks()` includes the "no data" key, per `na_key` (#269).
352    fn na_key_shown(&self) -> bool {
353        match self.na_key {
354            NaKey::Auto => self.na_count > 0,
355            NaKey::Always => true,
356            NaKey::Never => false,
357        }
358    }
359}
360
361impl Scale for ScaleFillBinned {
362    fn aesthetic(&self) -> Aesthetic {
363        self.aesthetic.clone()
364    }
365
366    fn train(&mut self, values: &[Value]) {
367        // See `seen`'s doc: the pipeline can submit the exact same layer's
368        // data to `train` more than once, which every other scale's
369        // min/max expansion tolerates for free but a running count can't.
370        if !self.seen.insert(signature(values)) {
371            return;
372        }
373        for v in values {
374            match v {
375                Value::Na => self.na_count += 1,
376                _ => {
377                    if let Some(f) = v.as_f64() {
378                        if f.is_finite() {
379                            let i = self.bin_index(f);
380                            self.counts[i] += 1;
381                        }
382                    }
383                }
384            }
385        }
386    }
387
388    /// Not used for position — like [`super::steps::ScaleColorSteps`], the
389    /// legend looks a value's colour up by its break label instead.
390    fn map(&self, _value: &Value) -> f64 {
391        0.0
392    }
393
394    fn breaks(&self) -> Vec<(f64, String)> {
395        let n = self.colors.len();
396        let (lo0, hi_n) = (self.breaks[0], self.breaks[n]);
397        let span = (hi_n - lo0).max(f64::EPSILON);
398        let mut out: Vec<(f64, String)> = (0..n)
399            .map(|i| {
400                let center = (self.breaks[i] + self.breaks[i + 1]) / 2.0;
401                ((center - lo0) / span, self.class_label(i))
402            })
403            .collect();
404        if self.na_key_shown() {
405            out.push((1.0, self.na_label_with_count()));
406        }
407        out
408    }
409
410    /// `count_reserve` (#271): pad every key's trailing `(count)` to at
411    /// least `count_reserve`'s digit width before measuring — the drawn
412    /// labels from [`breaks`](Scale::breaks) are untouched. `None` (the
413    /// default): identical to `breaks()`.
414    fn breaks_for_layout(&self) -> Vec<(f64, String)> {
415        match self.count_reserve {
416            None => self.breaks(),
417            Some(reserve) => {
418                let min_digits = reserve.to_string().len();
419                self.breaks()
420                    .into_iter()
421                    .map(|(pos, label)| (pos, Self::pad_trailing_count(&label, min_digits)))
422                    .collect()
423            }
424        }
425    }
426
427    fn name(&self) -> &str {
428        &self.name
429    }
430
431    fn set_name(&mut self, name: &str) {
432        self.name = name.to_string();
433    }
434
435    fn is_discrete(&self) -> bool {
436        true
437    }
438
439    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
440        if matches!(value, Value::Na) {
441            return Some(self.na_color);
442        }
443        if let Some(f) = value.as_f64() {
444            if f.is_finite() {
445                return Some(self.colors[self.bin_index(f)]);
446            }
447        }
448        if let Value::Str(s) = value {
449            if s == &self.na_label_with_count() {
450                return Some(self.na_color);
451            }
452            for i in 0..self.colors.len() {
453                if self.class_label(i) == *s {
454                    return Some(self.colors[i]);
455                }
456            }
457        }
458        None
459    }
460
461    fn domain(&self) -> Option<(f64, f64)> {
462        let n = self.colors.len();
463        Some((self.breaks[0], self.breaks[n]))
464    }
465
466    /// This scale's configured `na_pattern`, if any — the caller (a
467    /// geom's fill resolution, or the legend's "no data" key) only
468    /// consults this for a row/key that's already otherwise headed for
469    /// the flat `na_color` (`Value::Na`, or the "no data" legend-key
470    /// label itself); every ordinary bin colour is unaffected (#206).
471    fn na_fill_pattern(&self) -> Option<FillPattern> {
472        self.na_pattern
473    }
474
475    fn is_na_break(&self, label: &str) -> bool {
476        self.na_key_shown() && label == self.na_label_with_count()
477    }
478
479    fn validate(&self, warnings: &mut Vec<String>) {
480        if self.corrected {
481            warnings.push(
482                "scale_fill_binned: breaks need >= 2 increasing finite edges, with one colour \
483                 per bin — falling back to a default range/palette"
484                    .to_string(),
485            );
486        }
487    }
488
489    fn clone_box(&self) -> Box<dyn Scale> {
490        Box::new(self.clone())
491    }
492
493    fn reset_training(&mut self) {
494        self.counts.iter_mut().for_each(|c| *c = 0);
495        self.na_count = 0;
496        self.seen.clear();
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    fn scale() -> ScaleFillBinned {
505        ScaleFillBinned::new(
506            Aesthetic::Fill,
507            vec![0.0, 10.0, 50.0, 100.0],
508            vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
509        )
510    }
511
512    #[test]
513    fn bins_are_half_open_except_the_last() {
514        let s = scale();
515        assert_eq!(s.bin_index(0.0), 0);
516        assert_eq!(s.bin_index(9.999), 0);
517        assert_eq!(s.bin_index(10.0), 1); // right edge moves to the next bin...
518        assert_eq!(s.bin_index(49.999), 1);
519        assert_eq!(s.bin_index(50.0), 2);
520        assert_eq!(s.bin_index(100.0), 2); // ...except the very last, which is closed
521        assert_eq!(
522            s.bin_index(1000.0),
523            2,
524            "out-of-range clamps to the last bin"
525        );
526        assert_eq!(
527            s.bin_index(-50.0),
528            0,
529            "out-of-range clamps to the first bin"
530        );
531    }
532
533    #[test]
534    fn train_tallies_counts_and_na_separately() {
535        let mut s = scale();
536        s.train(&[
537            Value::Float(1.0),
538            Value::Float(5.0),
539            Value::Float(20.0),
540            Value::Float(99.0),
541            Value::Float(100.0),
542            Value::Na,
543            Value::Na,
544        ]);
545        assert_eq!(s.counts, vec![2, 1, 2]);
546        assert_eq!(s.na_count, 2);
547    }
548
549    /// `build.rs`'s own "final scale training pass" (and, for a faceted
550    /// plot, `compute_panel_scales`) re-submits a layer's already-trained
551    /// data to `Scale::train` a second time — every other scale tolerates
552    /// that for free (re-expanding a min/max domain with data it's already
553    /// seen is a no-op); a running count must recognise the repeat itself.
554    #[test]
555    fn training_the_same_batch_twice_does_not_double_count() {
556        let batch = [Value::Float(1.0), Value::Float(20.0), Value::Na];
557        let mut s = scale();
558        s.train(&batch);
559        s.train(&batch); // the pipeline's redundant re-training pass
560        assert_eq!(s.counts, vec![1, 1, 0]);
561        assert_eq!(s.na_count, 1);
562        // A genuinely different batch afterward still counts normally.
563        s.train(&[Value::Float(60.0)]);
564        assert_eq!(s.counts, vec![1, 1, 1]);
565    }
566
567    #[test]
568    fn breaks_label_each_class_with_its_count_and_na_with_its_own() {
569        let mut s = scale();
570        s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
571        let breaks = s.breaks();
572        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
573        assert_eq!(
574            labels,
575            vec!["0–10 (1)", "10–50 (1)", "50–100 (0)", "no data (1)"]
576        );
577    }
578
579    #[test]
580    fn no_na_key_when_nothing_is_missing() {
581        let mut s = scale();
582        s.train(&[Value::Float(1.0)]);
583        assert_eq!(s.breaks().len(), 3, "no trailing no-data entry");
584    }
585
586    #[test]
587    fn map_to_color_matches_the_bin_and_roundtrips_legend_labels() {
588        let mut s = scale();
589        s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
590        assert_eq!(s.map_to_color(&Value::Float(5.0)), Some((0, 0, 255)));
591        assert_eq!(s.map_to_color(&Value::Float(20.0)), Some((0, 255, 0)));
592        assert_eq!(s.map_to_color(&Value::Float(75.0)), Some((255, 0, 0)));
593        assert_eq!(s.map_to_color(&Value::Na), Some((127, 127, 127)));
594        for (_, label) in s.breaks() {
595            let via_label = s.map_to_color(&Value::Str(label.clone()));
596            assert!(via_label.is_some(), "{label} should round-trip to a colour");
597        }
598    }
599
600    #[test]
601    fn na_color_and_label_are_configurable() {
602        let mut s = scale().na_color((10, 20, 30)).na_label("missing");
603        s.train(&[Value::Na]);
604        assert_eq!(s.map_to_color(&Value::Na), Some((10, 20, 30)));
605        assert!(s.breaks().iter().any(|(_, l)| l == "missing (1)"));
606        assert_eq!(
607            s.map_to_color(&Value::Str("missing (1)".to_string())),
608            Some((10, 20, 30))
609        );
610    }
611
612    #[test]
613    fn na_pattern_is_none_until_set() {
614        let s = scale();
615        assert_eq!(s.na_fill_pattern(), None);
616    }
617
618    #[test]
619    fn na_pattern_is_reported_only_for_the_no_data_break_label() {
620        let pattern = FillPattern::stripe((10, 20, 30), (200, 210, 220));
621        let mut s = scale().na_pattern(pattern);
622        s.train(&[Value::Na, Value::Float(5.0)]);
623        assert_eq!(s.na_fill_pattern(), Some(pattern));
624        assert!(s.is_na_break(&s.na_label_with_count()));
625        // An ordinary bin's own label is never mistaken for the na key.
626        let ordinary = s.class_label(0);
627        assert!(!s.is_na_break(&ordinary));
628    }
629
630    #[test]
631    fn no_na_break_when_nothing_trained_as_na() {
632        // `na_count == 0`: `is_na_break` is false even for the label text
633        // that *would* be the na key, since the legend never shows one.
634        let s = scale().na_pattern(FillPattern::stripe((1, 2, 3), (4, 5, 6)));
635        assert!(!s.is_na_break(&s.na_label_with_count()));
636    }
637
638    #[test]
639    fn two_interpolates_colors_across_bins() {
640        let s = ScaleFillBinned::two(
641            Aesthetic::Fill,
642            vec![0.0, 25.0, 50.0, 75.0, 100.0],
643            (0, 0, 0),
644            (255, 255, 255),
645        );
646        assert_eq!(s.colors[0], (0, 0, 0));
647        assert_eq!(s.colors[3], (255, 255, 255));
648        assert!(s.colors[1].0 < s.colors[2].0);
649    }
650
651    #[test]
652    fn mismatched_colors_fall_back_and_warn() {
653        let s = ScaleFillBinned::new(
654            Aesthetic::Fill,
655            vec![0.0, 10.0, 20.0],
656            vec![(0, 0, 0)], // 2 bins need 2 colours, only 1 given
657        );
658        assert_eq!(s.colors.len(), 2);
659        let mut warnings = Vec::new();
660        s.validate(&mut warnings);
661        assert_eq!(warnings.len(), 1);
662    }
663
664    #[test]
665    fn too_few_breaks_falls_back_to_a_single_bin_rather_than_panicking() {
666        let s = ScaleFillBinned::new(Aesthetic::Fill, vec![5.0], vec![]);
667        assert_eq!(s.breaks.len(), 2);
668        assert_eq!(s.colors.len(), 1);
669        let mut warnings = Vec::new();
670        s.validate(&mut warnings);
671        assert_eq!(warnings.len(), 1);
672    }
673
674    #[test]
675    fn domain_spans_the_outer_edges() {
676        let s = scale();
677        assert_eq!(s.domain(), Some((0.0, 100.0)));
678    }
679
680    #[test]
681    fn reset_training_clears_counts_but_keeps_breaks_and_colors() {
682        let mut s = scale();
683        s.train(&[Value::Float(1.0), Value::Na]);
684        s.reset_training();
685        assert_eq!(s.counts, vec![0, 0, 0]);
686        assert_eq!(s.na_count, 0);
687        assert_eq!(s.breaks, vec![0.0, 10.0, 50.0, 100.0]);
688    }
689
690    // --- #240: duplicate (zero-width) class edges ----------------------
691
692    /// A duplicate edge at the *maximum* — the motivating case from #240
693    /// (a quantile split whose top class holds only the maximum, e.g.
694    /// `[35, 150, 150]` over two cells 35 and 150).
695    #[test]
696    fn duplicate_edge_at_the_max_keeps_both_classes_and_colours() {
697        let mut s = ScaleFillBinned::new(
698            Aesthetic::Fill,
699            vec![35.0, 150.0, 150.0],
700            vec![(0, 0, 255), (255, 0, 0)],
701        );
702        assert!(
703            !s.corrected,
704            "a legitimate zero-width bin is not a fallback"
705        );
706        assert_eq!(s.colors.len(), 2, "caller's requested class count is kept");
707        assert_eq!(
708            s.colors,
709            vec![(0, 0, 255), (255, 0, 0)],
710            "distinct colours per class"
711        );
712
713        s.train(&[Value::Float(35.0), Value::Float(150.0)]);
714        assert_eq!(s.counts, vec![1, 1], "each cell counted into its own class");
715        assert_eq!(s.bin_index(35.0), 0);
716        assert_eq!(
717            s.bin_index(150.0),
718            1,
719            "the zero-width bin claims its own edge value"
720        );
721        assert_eq!(s.map_to_color(&Value::Float(150.0)), Some((255, 0, 0)));
722
723        let breaks = s.breaks();
724        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
725        assert_eq!(
726            labels,
727            vec!["35–150 (1)", "150 (1)"],
728            "the zero-width class's label/count is a single value, not a 150–150 range"
729        );
730    }
731
732    /// A duplicate edge at the *minimum* — the lowest bin is zero-width and
733    /// must still claim the global minimum (left-closed first bin).
734    #[test]
735    fn duplicate_edge_at_the_min_keeps_both_classes_and_colours() {
736        let mut s = ScaleFillBinned::new(
737            Aesthetic::Fill,
738            vec![0.0, 0.0, 10.0],
739            vec![(0, 0, 255), (255, 0, 0)],
740        );
741        assert!(!s.corrected);
742        assert_eq!(s.colors.len(), 2);
743
744        s.train(&[Value::Float(0.0), Value::Float(0.0), Value::Float(5.0)]);
745        assert_eq!(s.counts, vec![2, 1]);
746        assert_eq!(s.bin_index(0.0), 0, "the zero-width bin claims the minimum");
747        assert_eq!(s.bin_index(5.0), 1);
748        assert_eq!(s.map_to_color(&Value::Float(0.0)), Some((0, 0, 255)));
749
750        let breaks = s.breaks();
751        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
752        assert_eq!(labels, vec!["0 (2)", "0–10 (1)"]);
753    }
754
755    /// A duplicate edge in the *middle* — between two otherwise ordinary
756    /// classes.
757    #[test]
758    fn duplicate_edge_in_the_middle_keeps_all_classes_and_colours() {
759        let mut s = ScaleFillBinned::new(
760            Aesthetic::Fill,
761            vec![0.0, 50.0, 50.0, 100.0],
762            vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
763        );
764        assert!(!s.corrected);
765        assert_eq!(s.colors.len(), 3, "all three requested classes survive");
766        assert_eq!(s.colors, vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)]);
767
768        s.train(&[Value::Float(25.0), Value::Float(50.0), Value::Float(75.0)]);
769        assert_eq!(
770            s.counts,
771            vec![1, 1, 1],
772            "the shared edge value goes to the middle (zero-width) bin"
773        );
774        assert_eq!(s.bin_index(25.0), 0);
775        assert_eq!(
776            s.bin_index(50.0),
777            1,
778            "the zero-width middle bin wins the tie over its neighbours"
779        );
780        assert_eq!(s.bin_index(75.0), 2);
781        assert_eq!(s.map_to_color(&Value::Float(50.0)), Some((0, 255, 0)));
782
783        let breaks = s.breaks();
784        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
785        assert_eq!(labels, vec!["0–50 (1)", "50 (1)", "50–100 (1)"]);
786    }
787
788    /// The fix must not perturb the ordinary (non-degenerate) case this
789    /// crate already shipped: same inputs, same bins/colours/labels as
790    /// before #240.
791    #[test]
792    fn non_degenerate_edges_are_unaffected() {
793        let s = scale();
794        assert_eq!(s.breaks, vec![0.0, 10.0, 50.0, 100.0]);
795        assert_eq!(s.colors, vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)]);
796        assert_eq!(s.bin_index(0.0), 0);
797        assert_eq!(s.bin_index(9.999), 0);
798        assert_eq!(s.bin_index(10.0), 1);
799        assert_eq!(s.bin_index(49.999), 1);
800        assert_eq!(s.bin_index(50.0), 2);
801        assert_eq!(s.bin_index(100.0), 2);
802    }
803
804    // ─── #269: `na_key` ──────────────────────────────────────────────
805
806    #[test]
807    fn na_key_always_lists_the_key_with_zero_count_when_nothing_is_missing() {
808        let mut s = scale().na_key(NaKey::Always);
809        s.train(&[Value::Float(1.0)]);
810        let breaks = s.breaks();
811        assert_eq!(breaks.len(), 4, "the no-data key is kept even at 0");
812        let (_, label) = breaks.last().unwrap();
813        assert_eq!(label, "no data (0)");
814        // And it still round-trips to the na colour via `map_to_color`.
815        assert_eq!(
816            s.map_to_color(&Value::Str(label.clone())),
817            Some((127, 127, 127))
818        );
819        assert!(s.is_na_break(label));
820    }
821
822    #[test]
823    fn na_key_never_hides_it_even_with_na_present() {
824        let mut s = scale().na_key(NaKey::Never);
825        s.train(&[Value::Float(1.0), Value::Na, Value::Na]);
826        let breaks = s.breaks();
827        assert_eq!(breaks.len(), 3, "no trailing no-data entry despite 2 NAs");
828        assert!(!s.is_na_break(&s.na_label_with_count()));
829    }
830
831    #[test]
832    fn na_key_auto_is_unchanged() {
833        // Default (no `.na_key(..)` call): identical to before #269.
834        let mut without_na = scale();
835        without_na.train(&[Value::Float(1.0)]);
836        assert_eq!(without_na.breaks().len(), 3);
837
838        let mut with_na = scale();
839        with_na.train(&[Value::Float(1.0), Value::Na]);
840        assert_eq!(with_na.breaks().len(), 4);
841    }
842
843    // ─── #271: `count_reserve` ───────────────────────────────────────
844
845    #[test]
846    fn count_reserve_pads_measured_labels_but_not_drawn_ones() {
847        let mut few = scale().count_reserve(9999);
848        few.train(&[Value::Float(1.0), Value::Float(20.0), Value::Float(75.0)]);
849        let drawn = few.breaks();
850        let labels: Vec<&str> = drawn.iter().map(|(_, l)| l.as_str()).collect();
851        assert_eq!(
852            labels,
853            vec!["0–10 (1)", "10–50 (1)", "50–100 (1)"],
854            "drawn labels unaffected"
855        );
856
857        let layout = few.breaks_for_layout();
858        let layout_labels: Vec<&str> = layout.iter().map(|(_, l)| l.as_str()).collect();
859        assert_eq!(
860            layout_labels,
861            vec!["0–10 (0001)", "10–50 (0001)", "50–100 (0001)"],
862            "layout labels reserve the 4-digit width"
863        );
864    }
865
866    /// The motivating case (#271): two scales sharing breaks/colours, one
867    /// trained with single-digit counts and the other with four-digit
868    /// counts, give *equal-length* `breaks_for_layout()` labels — the
869    /// same character count `legend_pitch`'s `text_width` measures —
870    /// once both reserve the wider digit width, even though the actual
871    /// digits (and so the drawn labels) differ.
872    #[test]
873    fn count_reserve_gives_equal_length_layout_labels_across_differing_counts() {
874        let mut few = scale().count_reserve(9999);
875        few.train(&[Value::Float(1.0), Value::Float(20.0), Value::Float(75.0)]);
876
877        let mut many = scale().count_reserve(9999);
878        let batch: Vec<Value> = (0..1234)
879            .flat_map(|_| [Value::Float(1.0), Value::Float(20.0), Value::Float(75.0)])
880            .collect();
881        many.train(&batch);
882
883        let few_layout = few.breaks_for_layout();
884        let many_layout = many.breaks_for_layout();
885        for (f, m) in few_layout.iter().zip(many_layout.iter()) {
886            assert_eq!(
887                f.1.len(),
888                m.1.len(),
889                "layout labels must measure the same width: {f:?} vs {m:?}"
890            );
891        }
892        // But the actually-drawn labels still differ, as they must.
893        assert_ne!(few.breaks(), many.breaks());
894    }
895
896    #[test]
897    fn count_reserve_is_none_by_default_and_layout_matches_breaks() {
898        let mut s = scale();
899        s.train(&[Value::Float(1.0), Value::Na]);
900        assert_eq!(s.breaks_for_layout(), s.breaks());
901    }
902
903    #[test]
904    fn count_reserve_never_shrinks_below_the_actual_count() {
905        // A reservation smaller than the count actually trained is a
906        // floor, not a cap — the real count's digit width still wins.
907        let mut s = scale().count_reserve(1);
908        let batch: Vec<Value> = (0..1234).map(|_| Value::Float(1.0)).collect();
909        s.train(&batch);
910        let layout = s.breaks_for_layout();
911        assert!(layout[0].1.contains("(1234)"), "{layout:?}");
912    }
913}