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