Skip to main content

ggplot_rs/scale/
binned.rs

1//! Binned fill/colour scale with *explicit* class breaks (R's
2//! `scale_fill_steps`/`scale_fill_fermenter` generalised to arbitrary,
3//! caller-supplied edges — ggplot2's `scale_fill_binned` with `breaks` set
4//! "manually"), backing a `::CLASSES` choropleth (sipemu/ggplot-rs#180).
5//!
6//! The break *maths* — quantile/Fisher-Jenks/equal-width/custom edges —
7//! stays the caller's job (anofox-visualization's `map/classes.rs`); this
8//! scale only bins against the edges it's given, tallies how many trained
9//! values fall in each bin, and draws a stepped legend with those counts
10//! plus a "no data" key for `Value::Na` (ggplot2's `na.value`).
11
12use std::collections::HashSet;
13
14use crate::aes::Aesthetic;
15use crate::data::Value;
16use crate::format::format_value;
17use crate::render::backend::FillPattern;
18
19use super::Scale;
20
21/// A coarse, order-sensitive fingerprint of a `train()` batch (FNV-1a over
22/// each value's length/discriminant/bits). See [`ScaleFillBinned::seen`]'s
23/// doc for why this exists.
24fn signature(values: &[Value]) -> u64 {
25    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
26    let mut mix = |x: u64| {
27        h ^= x;
28        h = h.wrapping_mul(0x0000_0100_0000_01b3);
29    };
30    mix(values.len() as u64);
31    for v in values {
32        match v {
33            Value::Na => mix(1),
34            _ => match v.as_f64() {
35                Some(f) => {
36                    mix(2);
37                    mix(f.to_bits());
38                }
39                None => mix(3),
40            },
41        }
42    }
43    h
44}
45
46/// Binned fill/colour scale: `n` classes from `n + 1` increasing edges,
47/// each with its own colour. See the [module docs](self) for how it
48/// relates to `scale_fill_steps`/`::CLASSES`.
49#[derive(Clone, Debug)]
50pub struct ScaleFillBinned {
51    aesthetic: Aesthetic,
52    /// `n + 1` increasing edges (`colors.len() == n`).
53    breaks: Vec<f64>,
54    colors: Vec<(u8, u8, u8)>,
55    /// Trained-value count per bin, same indices as `colors`.
56    counts: Vec<usize>,
57    /// Trained `Value::Na` count.
58    na_count: usize,
59    /// Fingerprints of every batch already folded into `counts`/`na_count`
60    /// (see [`signature`]). `Scale::train` can run more than once over the
61    /// *same* layer data — `build.rs`'s own "final scale training pass"
62    /// re-trains every layer's already-built data on top of the per-layer
63    /// pass `build_layer` just did, and faceting clones a scale set and
64    /// retrains it again per panel — which every other scale in this crate
65    /// tolerates for free because re-expanding a min/max domain with data
66    /// it has already seen is a no-op. A running *count* isn't naturally
67    /// idempotent that way, so this scale has to recognise "I've already
68    /// counted this exact batch" itself and skip it, rather than double
69    /// (or triple) class counts and the no-data count.
70    seen: HashSet<u64>,
71    /// ggplot2's `na.value` — colour for `Value::Na` and for the legend's
72    /// "no data" key. Default grey50 `(127, 127, 127)`. Overridden by
73    /// `na_pattern` when that's set (#206).
74    na_color: (u8, u8, u8),
75    /// A pattern fill (a thin hatch, by default) for `Value::Na` and the
76    /// legend's "no data" key, drawn instead of the flat `na_color` by
77    /// `geom_sf`/`geom_polygon`/`geom_rect`/`geom_tile` — a classed
78    /// choropleth's no-data cells then read as "we have no reading here"
79    /// rather than a greyed-out *value* easily mistaken for one (#206).
80    /// `None` (the default): `na_color` draws flat, as before.
81    na_pattern: Option<FillPattern>,
82    na_label: String,
83    name: String,
84    corrected: bool,
85}
86
87/// At least two increasing, finite edges: non-finite values dropped and
88/// sorted; fewer than two left falls back to `[0.0, 1.0]` (a single
89/// degenerate bin) rather than panicking.
90///
91/// Equal adjacent edges are kept rather than collapsed (sipemu/ggplot-rs#240):
92/// a quantile split (or any other caller-supplied break set) can legitimately
93/// repeat an edge, e.g. `[35.0, 150.0, 150.0]` for a class whose upper bin
94/// holds only the maximum. Deduping such edges away used to merge two
95/// classes into one and change the caller's requested class count; keeping
96/// them instead produces a *zero-width* bin, which [`bin_index`] and
97/// [`ScaleFillBinned::class_label`] give dedicated handling.
98fn sorted_edges(breaks: Vec<f64>) -> (Vec<f64>, bool) {
99    let mut v: Vec<f64> = breaks.into_iter().filter(|f| f.is_finite()).collect();
100    v.sort_by(f64::total_cmp);
101    if v.len() >= 2 {
102        (v, false)
103    } else {
104        (vec![0.0, 1.0], true)
105    }
106}
107
108/// Linear RGB interpolation at `t` in `[0, 1]`.
109fn lerp_color(low: (u8, u8, u8), high: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
110    let t = t.clamp(0.0, 1.0);
111    let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
112    (
113        lerp(low.0, high.0),
114        lerp(low.1, high.1),
115        lerp(low.2, high.2),
116    )
117}
118
119/// `n` colours evenly interpolated from `low` to `high` (`n == 1` picks the
120/// midpoint).
121fn default_colors(n: usize, low: (u8, u8, u8), high: (u8, u8, u8)) -> Vec<(u8, u8, u8)> {
122    (0..n)
123        .map(|i| {
124            let t = if n <= 1 {
125                0.5
126            } else {
127                i as f64 / (n - 1) as f64
128            };
129            lerp_color(low, high, t)
130        })
131        .collect()
132}
133
134/// This crate's default two-colour ramp (matches [`super::steps::ScaleColorSteps`]'s).
135const DEFAULT_LOW: (u8, u8, u8) = (50, 50, 200);
136const DEFAULT_HIGH: (u8, u8, u8) = (200, 50, 50);
137
138impl ScaleFillBinned {
139    /// `n + 1` increasing `breaks` with an explicit colour per bin
140    /// (`colors.len()` should be `breaks.len() - 1`; a mismatch falls back
141    /// to the default two-colour ramp and is reported by
142    /// [`validate`](Scale::validate)).
143    pub fn new(aesthetic: Aesthetic, breaks: Vec<f64>, colors: Vec<(u8, u8, u8)>) -> Self {
144        let (breaks, edges_corrected) = sorted_edges(breaks);
145        let n = breaks.len() - 1;
146        let (colors, colors_corrected) = if colors.len() == n {
147            (colors, false)
148        } else {
149            (default_colors(n, DEFAULT_LOW, DEFAULT_HIGH), true)
150        };
151        ScaleFillBinned {
152            aesthetic,
153            breaks,
154            colors,
155            counts: vec![0; n],
156            na_count: 0,
157            seen: HashSet::new(),
158            na_color: (127, 127, 127),
159            na_pattern: None,
160            na_label: "no data".to_string(),
161            name: String::new(),
162            corrected: edges_corrected || colors_corrected,
163        }
164    }
165
166    /// `n + 1` increasing `breaks`, coloured by interpolating `low`→`high`
167    /// across the `n` bins (R's `scale_fill_steps(breaks = ...)`).
168    pub fn two(
169        aesthetic: Aesthetic,
170        breaks: Vec<f64>,
171        low: (u8, u8, u8),
172        high: (u8, u8, u8),
173    ) -> Self {
174        let (breaks, corrected) = sorted_edges(breaks);
175        let n = breaks.len() - 1;
176        ScaleFillBinned {
177            aesthetic,
178            colors: default_colors(n, low, high),
179            counts: vec![0; n],
180            breaks,
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            name: String::new(),
187            corrected,
188        }
189    }
190
191    /// Colour for `Value::Na` and the legend's "no data" key (ggplot2's
192    /// `na.value`). Default grey50 `(127, 127, 127)`. Overridden for
193    /// drawing (not for the legend count) by [`na_pattern`](Self::na_pattern)
194    /// when that's also set.
195    pub fn na_color(mut self, color: (u8, u8, u8)) -> Self {
196        self.na_color = color;
197        self
198    }
199
200    /// A pattern fill for `Value::Na` and the legend's "no data" key,
201    /// drawn instead of the flat [`na_color`](Self::na_color) — a classed
202    /// choropleth's no-data cells read as "no reading", not a muted
203    /// *value* (#206). `FillPattern::stripe(fg, bg)` is a reasonable
204    /// default hatch; `bg` is what a pattern-less backend (`plotters`)
205    /// falls back to.
206    pub fn na_pattern(mut self, pattern: FillPattern) -> Self {
207        self.na_pattern = Some(pattern);
208        self
209    }
210
211    /// Label for the "no data" legend key. Default `"no data"`.
212    pub fn na_label(mut self, label: &str) -> Self {
213        self.na_label = label.to_string();
214        self
215    }
216
217    /// The bin index for `v` (clamped into range first).
218    ///
219    /// An ordinary bin `i` is half-open, `[breaks[i], breaks[i+1])`; the
220    /// last bin is closed at its upper edge too, so the maximum
221    /// trained/data value lands in it rather than falling just outside
222    /// every bin. A **zero-width** bin (`breaks[i] == breaks[i+1]`, e.g.
223    /// from a quantile split whose top class holds only the maximum,
224    /// sipemu/ggplot-rs#240) is closed at *both* ends instead, so it
225    /// captures values equal to its single shared edge rather than losing
226    /// them to whichever neighbour the half-open rule would otherwise hand
227    /// them to. Bins are scanned low to high and the first match wins, so
228    /// when several consecutive bins share the same point the lowest-index
229    /// one claims it (ggplot2's `cut(..., right = TRUE)` convention, with
230    /// the first bin additionally closed on the left so the global minimum
231    /// is never dropped).
232    fn bin_index(&self, v: f64) -> usize {
233        let n = self.colors.len();
234        let v = v.clamp(self.breaks[0], self.breaks[n]);
235        for i in 0..n {
236            let (lo, hi) = (self.breaks[i], self.breaks[i + 1]);
237            let hit = if i == n - 1 {
238                v >= lo && v <= hi // last bin: closed at both ends
239            } else if lo == hi {
240                v == lo // zero-width bin: closed at both ends
241            } else {
242                v >= lo && v < hi // ordinary bin: half-open
243            };
244            if hit {
245                return i;
246            }
247        }
248        n - 1
249    }
250
251    /// `"{lo}–{hi} ({count})"` for bin `i`, or just `"{v} ({count})"` when
252    /// `i` is a zero-width bin (`breaks[i] == breaks[i + 1]`, #240) — a
253    /// range like "150–150" would misstate a bin that in fact holds only
254    /// the single value 150.
255    fn class_label(&self, i: usize) -> String {
256        let (lo, hi) = (self.breaks[i], self.breaks[i + 1]);
257        if lo == hi {
258            format!("{} ({})", format_value(&Value::Float(lo)), self.counts[i])
259        } else {
260            format!(
261                "{}–{} ({})",
262                format_value(&Value::Float(lo)),
263                format_value(&Value::Float(hi)),
264                self.counts[i]
265            )
266        }
267    }
268
269    /// `"{na_label} ({na_count})"`.
270    fn na_label_with_count(&self) -> String {
271        format!("{} ({})", self.na_label, self.na_count)
272    }
273}
274
275impl Scale for ScaleFillBinned {
276    fn aesthetic(&self) -> Aesthetic {
277        self.aesthetic.clone()
278    }
279
280    fn train(&mut self, values: &[Value]) {
281        // See `seen`'s doc: the pipeline can submit the exact same layer's
282        // data to `train` more than once, which every other scale's
283        // min/max expansion tolerates for free but a running count can't.
284        if !self.seen.insert(signature(values)) {
285            return;
286        }
287        for v in values {
288            match v {
289                Value::Na => self.na_count += 1,
290                _ => {
291                    if let Some(f) = v.as_f64() {
292                        if f.is_finite() {
293                            let i = self.bin_index(f);
294                            self.counts[i] += 1;
295                        }
296                    }
297                }
298            }
299        }
300    }
301
302    /// Not used for position — like [`super::steps::ScaleColorSteps`], the
303    /// legend looks a value's colour up by its break label instead.
304    fn map(&self, _value: &Value) -> f64 {
305        0.0
306    }
307
308    fn breaks(&self) -> Vec<(f64, String)> {
309        let n = self.colors.len();
310        let (lo0, hi_n) = (self.breaks[0], self.breaks[n]);
311        let span = (hi_n - lo0).max(f64::EPSILON);
312        let mut out: Vec<(f64, String)> = (0..n)
313            .map(|i| {
314                let center = (self.breaks[i] + self.breaks[i + 1]) / 2.0;
315                ((center - lo0) / span, self.class_label(i))
316            })
317            .collect();
318        if self.na_count > 0 {
319            out.push((1.0, self.na_label_with_count()));
320        }
321        out
322    }
323
324    fn name(&self) -> &str {
325        &self.name
326    }
327
328    fn set_name(&mut self, name: &str) {
329        self.name = name.to_string();
330    }
331
332    fn is_discrete(&self) -> bool {
333        true
334    }
335
336    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
337        if matches!(value, Value::Na) {
338            return Some(self.na_color);
339        }
340        if let Some(f) = value.as_f64() {
341            if f.is_finite() {
342                return Some(self.colors[self.bin_index(f)]);
343            }
344        }
345        if let Value::Str(s) = value {
346            if s == &self.na_label_with_count() {
347                return Some(self.na_color);
348            }
349            for i in 0..self.colors.len() {
350                if self.class_label(i) == *s {
351                    return Some(self.colors[i]);
352                }
353            }
354        }
355        None
356    }
357
358    fn domain(&self) -> Option<(f64, f64)> {
359        let n = self.colors.len();
360        Some((self.breaks[0], self.breaks[n]))
361    }
362
363    /// This scale's configured `na_pattern`, if any — the caller (a
364    /// geom's fill resolution, or the legend's "no data" key) only
365    /// consults this for a row/key that's already otherwise headed for
366    /// the flat `na_color` (`Value::Na`, or the "no data" legend-key
367    /// label itself); every ordinary bin colour is unaffected (#206).
368    fn na_fill_pattern(&self) -> Option<FillPattern> {
369        self.na_pattern
370    }
371
372    fn is_na_break(&self, label: &str) -> bool {
373        self.na_count > 0 && label == self.na_label_with_count()
374    }
375
376    fn validate(&self, warnings: &mut Vec<String>) {
377        if self.corrected {
378            warnings.push(
379                "scale_fill_binned: breaks need >= 2 increasing finite edges, with one colour \
380                 per bin — falling back to a default range/palette"
381                    .to_string(),
382            );
383        }
384    }
385
386    fn clone_box(&self) -> Box<dyn Scale> {
387        Box::new(self.clone())
388    }
389
390    fn reset_training(&mut self) {
391        self.counts.iter_mut().for_each(|c| *c = 0);
392        self.na_count = 0;
393        self.seen.clear();
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn scale() -> ScaleFillBinned {
402        ScaleFillBinned::new(
403            Aesthetic::Fill,
404            vec![0.0, 10.0, 50.0, 100.0],
405            vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
406        )
407    }
408
409    #[test]
410    fn bins_are_half_open_except_the_last() {
411        let s = scale();
412        assert_eq!(s.bin_index(0.0), 0);
413        assert_eq!(s.bin_index(9.999), 0);
414        assert_eq!(s.bin_index(10.0), 1); // right edge moves to the next bin...
415        assert_eq!(s.bin_index(49.999), 1);
416        assert_eq!(s.bin_index(50.0), 2);
417        assert_eq!(s.bin_index(100.0), 2); // ...except the very last, which is closed
418        assert_eq!(
419            s.bin_index(1000.0),
420            2,
421            "out-of-range clamps to the last bin"
422        );
423        assert_eq!(
424            s.bin_index(-50.0),
425            0,
426            "out-of-range clamps to the first bin"
427        );
428    }
429
430    #[test]
431    fn train_tallies_counts_and_na_separately() {
432        let mut s = scale();
433        s.train(&[
434            Value::Float(1.0),
435            Value::Float(5.0),
436            Value::Float(20.0),
437            Value::Float(99.0),
438            Value::Float(100.0),
439            Value::Na,
440            Value::Na,
441        ]);
442        assert_eq!(s.counts, vec![2, 1, 2]);
443        assert_eq!(s.na_count, 2);
444    }
445
446    /// `build.rs`'s own "final scale training pass" (and, for a faceted
447    /// plot, `compute_panel_scales`) re-submits a layer's already-trained
448    /// data to `Scale::train` a second time — every other scale tolerates
449    /// that for free (re-expanding a min/max domain with data it's already
450    /// seen is a no-op); a running count must recognise the repeat itself.
451    #[test]
452    fn training_the_same_batch_twice_does_not_double_count() {
453        let batch = [Value::Float(1.0), Value::Float(20.0), Value::Na];
454        let mut s = scale();
455        s.train(&batch);
456        s.train(&batch); // the pipeline's redundant re-training pass
457        assert_eq!(s.counts, vec![1, 1, 0]);
458        assert_eq!(s.na_count, 1);
459        // A genuinely different batch afterward still counts normally.
460        s.train(&[Value::Float(60.0)]);
461        assert_eq!(s.counts, vec![1, 1, 1]);
462    }
463
464    #[test]
465    fn breaks_label_each_class_with_its_count_and_na_with_its_own() {
466        let mut s = scale();
467        s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
468        let breaks = s.breaks();
469        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
470        assert_eq!(
471            labels,
472            vec!["0–10 (1)", "10–50 (1)", "50–100 (0)", "no data (1)"]
473        );
474    }
475
476    #[test]
477    fn no_na_key_when_nothing_is_missing() {
478        let mut s = scale();
479        s.train(&[Value::Float(1.0)]);
480        assert_eq!(s.breaks().len(), 3, "no trailing no-data entry");
481    }
482
483    #[test]
484    fn map_to_color_matches_the_bin_and_roundtrips_legend_labels() {
485        let mut s = scale();
486        s.train(&[Value::Float(1.0), Value::Float(20.0), Value::Na]);
487        assert_eq!(s.map_to_color(&Value::Float(5.0)), Some((0, 0, 255)));
488        assert_eq!(s.map_to_color(&Value::Float(20.0)), Some((0, 255, 0)));
489        assert_eq!(s.map_to_color(&Value::Float(75.0)), Some((255, 0, 0)));
490        assert_eq!(s.map_to_color(&Value::Na), Some((127, 127, 127)));
491        for (_, label) in s.breaks() {
492            let via_label = s.map_to_color(&Value::Str(label.clone()));
493            assert!(via_label.is_some(), "{label} should round-trip to a colour");
494        }
495    }
496
497    #[test]
498    fn na_color_and_label_are_configurable() {
499        let mut s = scale().na_color((10, 20, 30)).na_label("missing");
500        s.train(&[Value::Na]);
501        assert_eq!(s.map_to_color(&Value::Na), Some((10, 20, 30)));
502        assert!(s.breaks().iter().any(|(_, l)| l == "missing (1)"));
503        assert_eq!(
504            s.map_to_color(&Value::Str("missing (1)".to_string())),
505            Some((10, 20, 30))
506        );
507    }
508
509    #[test]
510    fn na_pattern_is_none_until_set() {
511        let s = scale();
512        assert_eq!(s.na_fill_pattern(), None);
513    }
514
515    #[test]
516    fn na_pattern_is_reported_only_for_the_no_data_break_label() {
517        let pattern = FillPattern::stripe((10, 20, 30), (200, 210, 220));
518        let mut s = scale().na_pattern(pattern);
519        s.train(&[Value::Na, Value::Float(5.0)]);
520        assert_eq!(s.na_fill_pattern(), Some(pattern));
521        assert!(s.is_na_break(&s.na_label_with_count()));
522        // An ordinary bin's own label is never mistaken for the na key.
523        let ordinary = s.class_label(0);
524        assert!(!s.is_na_break(&ordinary));
525    }
526
527    #[test]
528    fn no_na_break_when_nothing_trained_as_na() {
529        // `na_count == 0`: `is_na_break` is false even for the label text
530        // that *would* be the na key, since the legend never shows one.
531        let s = scale().na_pattern(FillPattern::stripe((1, 2, 3), (4, 5, 6)));
532        assert!(!s.is_na_break(&s.na_label_with_count()));
533    }
534
535    #[test]
536    fn two_interpolates_colors_across_bins() {
537        let s = ScaleFillBinned::two(
538            Aesthetic::Fill,
539            vec![0.0, 25.0, 50.0, 75.0, 100.0],
540            (0, 0, 0),
541            (255, 255, 255),
542        );
543        assert_eq!(s.colors[0], (0, 0, 0));
544        assert_eq!(s.colors[3], (255, 255, 255));
545        assert!(s.colors[1].0 < s.colors[2].0);
546    }
547
548    #[test]
549    fn mismatched_colors_fall_back_and_warn() {
550        let s = ScaleFillBinned::new(
551            Aesthetic::Fill,
552            vec![0.0, 10.0, 20.0],
553            vec![(0, 0, 0)], // 2 bins need 2 colours, only 1 given
554        );
555        assert_eq!(s.colors.len(), 2);
556        let mut warnings = Vec::new();
557        s.validate(&mut warnings);
558        assert_eq!(warnings.len(), 1);
559    }
560
561    #[test]
562    fn too_few_breaks_falls_back_to_a_single_bin_rather_than_panicking() {
563        let s = ScaleFillBinned::new(Aesthetic::Fill, vec![5.0], vec![]);
564        assert_eq!(s.breaks.len(), 2);
565        assert_eq!(s.colors.len(), 1);
566        let mut warnings = Vec::new();
567        s.validate(&mut warnings);
568        assert_eq!(warnings.len(), 1);
569    }
570
571    #[test]
572    fn domain_spans_the_outer_edges() {
573        let s = scale();
574        assert_eq!(s.domain(), Some((0.0, 100.0)));
575    }
576
577    #[test]
578    fn reset_training_clears_counts_but_keeps_breaks_and_colors() {
579        let mut s = scale();
580        s.train(&[Value::Float(1.0), Value::Na]);
581        s.reset_training();
582        assert_eq!(s.counts, vec![0, 0, 0]);
583        assert_eq!(s.na_count, 0);
584        assert_eq!(s.breaks, vec![0.0, 10.0, 50.0, 100.0]);
585    }
586
587    // --- #240: duplicate (zero-width) class edges ----------------------
588
589    /// A duplicate edge at the *maximum* — the motivating case from #240
590    /// (a quantile split whose top class holds only the maximum, e.g.
591    /// `[35, 150, 150]` over two cells 35 and 150).
592    #[test]
593    fn duplicate_edge_at_the_max_keeps_both_classes_and_colours() {
594        let mut s = ScaleFillBinned::new(
595            Aesthetic::Fill,
596            vec![35.0, 150.0, 150.0],
597            vec![(0, 0, 255), (255, 0, 0)],
598        );
599        assert!(
600            !s.corrected,
601            "a legitimate zero-width bin is not a fallback"
602        );
603        assert_eq!(s.colors.len(), 2, "caller's requested class count is kept");
604        assert_eq!(
605            s.colors,
606            vec![(0, 0, 255), (255, 0, 0)],
607            "distinct colours per class"
608        );
609
610        s.train(&[Value::Float(35.0), Value::Float(150.0)]);
611        assert_eq!(s.counts, vec![1, 1], "each cell counted into its own class");
612        assert_eq!(s.bin_index(35.0), 0);
613        assert_eq!(
614            s.bin_index(150.0),
615            1,
616            "the zero-width bin claims its own edge value"
617        );
618        assert_eq!(s.map_to_color(&Value::Float(150.0)), Some((255, 0, 0)));
619
620        let breaks = s.breaks();
621        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
622        assert_eq!(
623            labels,
624            vec!["35–150 (1)", "150 (1)"],
625            "the zero-width class's label/count is a single value, not a 150–150 range"
626        );
627    }
628
629    /// A duplicate edge at the *minimum* — the lowest bin is zero-width and
630    /// must still claim the global minimum (left-closed first bin).
631    #[test]
632    fn duplicate_edge_at_the_min_keeps_both_classes_and_colours() {
633        let mut s = ScaleFillBinned::new(
634            Aesthetic::Fill,
635            vec![0.0, 0.0, 10.0],
636            vec![(0, 0, 255), (255, 0, 0)],
637        );
638        assert!(!s.corrected);
639        assert_eq!(s.colors.len(), 2);
640
641        s.train(&[Value::Float(0.0), Value::Float(0.0), Value::Float(5.0)]);
642        assert_eq!(s.counts, vec![2, 1]);
643        assert_eq!(s.bin_index(0.0), 0, "the zero-width bin claims the minimum");
644        assert_eq!(s.bin_index(5.0), 1);
645        assert_eq!(s.map_to_color(&Value::Float(0.0)), Some((0, 0, 255)));
646
647        let breaks = s.breaks();
648        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
649        assert_eq!(labels, vec!["0 (2)", "0–10 (1)"]);
650    }
651
652    /// A duplicate edge in the *middle* — between two otherwise ordinary
653    /// classes.
654    #[test]
655    fn duplicate_edge_in_the_middle_keeps_all_classes_and_colours() {
656        let mut s = ScaleFillBinned::new(
657            Aesthetic::Fill,
658            vec![0.0, 50.0, 50.0, 100.0],
659            vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)],
660        );
661        assert!(!s.corrected);
662        assert_eq!(s.colors.len(), 3, "all three requested classes survive");
663        assert_eq!(s.colors, vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)]);
664
665        s.train(&[Value::Float(25.0), Value::Float(50.0), Value::Float(75.0)]);
666        assert_eq!(
667            s.counts,
668            vec![1, 1, 1],
669            "the shared edge value goes to the middle (zero-width) bin"
670        );
671        assert_eq!(s.bin_index(25.0), 0);
672        assert_eq!(
673            s.bin_index(50.0),
674            1,
675            "the zero-width middle bin wins the tie over its neighbours"
676        );
677        assert_eq!(s.bin_index(75.0), 2);
678        assert_eq!(s.map_to_color(&Value::Float(50.0)), Some((0, 255, 0)));
679
680        let breaks = s.breaks();
681        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
682        assert_eq!(labels, vec!["0–50 (1)", "50 (1)", "50–100 (1)"]);
683    }
684
685    /// The fix must not perturb the ordinary (non-degenerate) case this
686    /// crate already shipped: same inputs, same bins/colours/labels as
687    /// before #240.
688    #[test]
689    fn non_degenerate_edges_are_unaffected() {
690        let s = scale();
691        assert_eq!(s.breaks, vec![0.0, 10.0, 50.0, 100.0]);
692        assert_eq!(s.colors, vec![(0, 0, 255), (0, 255, 0), (255, 0, 0)]);
693        assert_eq!(s.bin_index(0.0), 0);
694        assert_eq!(s.bin_index(9.999), 0);
695        assert_eq!(s.bin_index(10.0), 1);
696        assert_eq!(s.bin_index(49.999), 1);
697        assert_eq!(s.bin_index(50.0), 2);
698        assert_eq!(s.bin_index(100.0), 2);
699    }
700}