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