Skip to main content

ggplot_rs/scale/
color.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3use std::collections::HashMap;
4
5use super::util::nice_step;
6use super::Scale;
7
8/// RGBA color representation.
9#[derive(Clone, Debug, Copy)]
10pub struct RGBAColor {
11    pub r: u8,
12    pub g: u8,
13    pub b: u8,
14    pub a: f64,
15}
16
17impl RGBAColor {
18    pub fn new(r: u8, g: u8, b: u8) -> Self {
19        RGBAColor { r, g, b, a: 1.0 }
20    }
21
22    pub fn with_alpha(mut self, a: f64) -> Self {
23        self.a = a;
24        self
25    }
26
27    /// Interpolate between two colors.
28    pub fn lerp(&self, other: &RGBAColor, t: f64) -> RGBAColor {
29        let t = t.clamp(0.0, 1.0);
30        RGBAColor {
31            r: (self.r as f64 * (1.0 - t) + other.r as f64 * t) as u8,
32            g: (self.g as f64 * (1.0 - t) + other.g as f64 * t) as u8,
33            b: (self.b as f64 * (1.0 - t) + other.b as f64 * t) as u8,
34            a: self.a * (1.0 - t) + other.a * t,
35        }
36    }
37}
38
39/// Fixed 8-colour fallback palette (the first 3 match ggplot2's defaults).
40/// Discrete colour scales default to [`HuePalette::default()`] instead —
41/// ggplot2's `hue_pal()`, spread over the number of levels (#408).
42pub const DEFAULT_PALETTE: &[RGBAColor] = &[
43    RGBAColor {
44        r: 248,
45        g: 118,
46        b: 109,
47        a: 1.0,
48    }, // red
49    RGBAColor {
50        r: 0,
51        g: 186,
52        b: 56,
53        a: 1.0,
54    }, // green
55    RGBAColor {
56        r: 97,
57        g: 156,
58        b: 255,
59        a: 1.0,
60    }, // blue
61    RGBAColor {
62        r: 163,
63        g: 103,
64        b: 203,
65        a: 1.0,
66    }, // purple
67    RGBAColor {
68        r: 231,
69        g: 138,
70        b: 0,
71        a: 1.0,
72    }, // orange
73    RGBAColor {
74        r: 0,
75        g: 191,
76        b: 196,
77        a: 1.0,
78    }, // cyan
79    RGBAColor {
80        r: 199,
81        g: 124,
82        b: 255,
83        a: 1.0,
84    }, // violet
85    RGBAColor {
86        r: 127,
87        g: 127,
88        b: 127,
89        a: 1.0,
90    }, // gray
91];
92
93/// Discrete color scale — maps categories to distinct colors.
94#[derive(Clone, Debug)]
95pub struct ScaleColorDiscrete {
96    aesthetic: Aesthetic,
97    name: String,
98    levels: Vec<String>,
99    /// level → index in `levels`, so training/lookup are O(1) (many-category
100    /// fills would otherwise be quadratic). Kept in sync with `levels`.
101    level_index: HashMap<String, usize>,
102    palette: Vec<RGBAColor>,
103    /// Keep levels in sorted (lexicographic) order instead of first-seen
104    /// order — see [`ScaleColorDiscrete::sorted`].
105    sorted: bool,
106    /// A palette computed for the number of levels (#408: ggplot2's
107    /// `hue_pal()` / `viridis_pal()` spread their colours over `n`);
108    /// overrides `palette` when set.
109    generated: Option<GeneratedPalette>,
110}
111
112/// ggplot2's `scale_colour_hue()` parameters (`scales::hue_pal`): `n` hues
113/// evenly spaced over `h` (degrees), at chroma `c` and luminance `l`.
114#[derive(Clone, Debug, PartialEq)]
115pub struct HuePalette {
116    pub h: (f64, f64),
117    pub c: f64,
118    pub l: f64,
119    pub h_start: f64,
120    /// `1`, or `-1` to reverse.
121    pub direction: i32,
122}
123
124impl Default for HuePalette {
125    /// ggplot2's default discrete palette.
126    fn default() -> Self {
127        HuePalette {
128            h: (15.0, 375.0),
129            c: 100.0,
130            l: 65.0,
131            h_start: 0.0,
132            direction: 1,
133        }
134    }
135}
136
137/// A discrete palette generated for the number of levels.
138#[derive(Clone, Debug, PartialEq)]
139pub enum GeneratedPalette {
140    Hue(HuePalette),
141    /// `scale_colour_viridis_d()`: `n` colours sampled from a viridis map
142    /// (`option` 'A' magma, 'B' inferno, 'C' plasma, 'D' viridis, 'E'
143    /// cividis, 'F' rocket, 'G' mako, 'H' turbo) between
144    /// `begin` and `end`, reversed with `direction = -1`.
145    Viridis {
146        option: char,
147        begin: f64,
148        end: f64,
149        direction: i32,
150    },
151}
152
153impl GeneratedPalette {
154    /// Colour `i` of the palette for `n` levels — the same as
155    /// `colors(n)[i]`, without building the whole palette (this runs per
156    /// mapped value).
157    pub fn color_at(&self, i: usize, n: usize) -> RGBAColor {
158        match self {
159            GeneratedPalette::Hue(p) => {
160                let n = n.max(1);
161                let i = if p.direction == -1 {
162                    n - 1 - i % n
163                } else {
164                    i % n
165                };
166                hue_color(i, n, p)
167            }
168            _ => {
169                let pal = self.colors(n);
170                pal[i % pal.len()]
171            }
172        }
173    }
174
175    /// The palette for `n` levels.
176    pub fn colors(&self, n: usize) -> Vec<RGBAColor> {
177        let n = n.max(1);
178        match self {
179            GeneratedPalette::Hue(p) => hue_palette(n, p),
180            GeneratedPalette::Viridis {
181                option,
182                begin,
183                end,
184                direction,
185            } => {
186                let opt = option.to_ascii_uppercase();
187                let lut = super::viridis_lut::VIRIDIS_LUT
188                    .iter()
189                    .find(|(o, _)| *o == opt)
190                    .map_or(&super::viridis_lut::VIRIDIS_LUT[3].1, |(_, l)| l);
191                // Linear interpolation on the 256-entry map (viridisLite
192                // splines in Lab; the difference is ≤ 1 per channel).
193                let at = |t: f64| {
194                    let f = t.clamp(0.0, 1.0) * 255.0;
195                    let (i, j) = (f.floor() as usize, (f.ceil() as usize).min(255));
196                    let w = f - i as f64;
197                    let mix = |k: usize| {
198                        (lut[i][k] as f64 * (1.0 - w) + lut[j][k] as f64 * w).round() as u8
199                    };
200                    RGBAColor::new(mix(0), mix(1), mix(2))
201                };
202                let mut out: Vec<RGBAColor> = (0..n)
203                    .map(|i| {
204                        let t = if n == 1 {
205                            *begin
206                        } else {
207                            begin + (end - begin) * i as f64 / (n - 1) as f64
208                        };
209                        at(t)
210                    })
211                    .collect();
212                if *direction < 0 {
213                    out.reverse();
214                }
215                out
216            }
217        }
218    }
219}
220
221/// `grDevices::hcl(h, c, l)` (CIE-LUV polar → sRGB, D65), R's implementation.
222pub fn hcl_color(h: f64, c: f64, l: f64) -> RGBAColor {
223    const WHITE_Y: f64 = 100.0;
224    const WHITE_U: f64 = 0.1978398;
225    const WHITE_V: f64 = 0.4683363;
226    let hr = h.to_radians();
227    let (u_, v_) = (c * hr.cos(), c * hr.sin());
228    let (x, y, z) = if l <= 0.0 && u_ == 0.0 && v_ == 0.0 {
229        (0.0, 0.0, 0.0)
230    } else {
231        let y = WHITE_Y
232            * if l > 7.999592 {
233                ((l + 16.0) / 116.0).powi(3)
234            } else {
235                l / 903.3
236            };
237        let u = u_ / (13.0 * l) + WHITE_U;
238        let v = v_ / (13.0 * l) + WHITE_V;
239        let x = 9.0 * y * u / (4.0 * v);
240        let z = -x / 3.0 - 5.0 * y + 3.0 * y / v;
241        (x, y, z)
242    };
243    let gamma = |u: f64| {
244        let u = u / WHITE_Y;
245        if u > 0.00304 {
246            1.055 * u.powf(1.0 / 2.4) - 0.055
247        } else {
248            12.92 * u
249        }
250    };
251    let byte = |v: f64| (255.0 * v.clamp(0.0, 1.0) + 0.5) as u8;
252    RGBAColor::new(
253        byte(gamma(3.240479 * x - 1.537150 * y - 0.498535 * z)),
254        byte(gamma(-0.969256 * x + 1.875992 * y + 0.041556 * z)),
255        byte(gamma(0.055648 * x - 0.204043 * y + 1.057311 * z)),
256    )
257}
258
259/// `scales::hue_pal(h, c, l, h.start, direction)(n)`.
260pub fn hue_palette(n: usize, p: &HuePalette) -> Vec<RGBAColor> {
261    let n = n.max(1);
262    let mut out: Vec<RGBAColor> = (0..n).map(|i| hue_color(i, n, p)).collect();
263    if p.direction == -1 {
264        out.reverse();
265    }
266    out
267}
268
269/// Hue `i` of `n` (before any `direction` reversal).
270fn hue_color(i: usize, n: usize, p: &HuePalette) -> RGBAColor {
271    let (h0, mut h1) = p.h;
272    if (h1 - h0).rem_euclid(360.0) < 1.0 {
273        h1 -= 360.0 / n as f64;
274    }
275    let h = if n == 1 {
276        h0
277    } else {
278        h0 + (h1 - h0) * i as f64 / (n - 1) as f64
279    };
280    hcl_color((h + p.h_start).rem_euclid(360.0), p.c, p.l)
281}
282
283impl ScaleColorDiscrete {
284    pub fn new(aesthetic: Aesthetic) -> Self {
285        ScaleColorDiscrete {
286            aesthetic,
287            name: String::new(),
288            levels: Vec::new(),
289            level_index: HashMap::new(),
290            palette: DEFAULT_PALETTE.to_vec(),
291            sorted: false,
292            // ggplot2's default: `hue_pal()` over the number of levels.
293            generated: Some(GeneratedPalette::Hue(HuePalette::default())),
294        }
295    }
296
297    /// Colours computed for the number of levels (ggplot2's `hue_pal()` /
298    /// `viridis_pal()`), e.g. `GeneratedPalette::Hue(HuePalette::default())`
299    /// — exactly ggplot2's default colours for any number of groups.
300    pub fn with_generated(mut self, p: GeneratedPalette) -> Self {
301        self.generated = Some(p);
302        self
303    }
304
305    /// Order levels lexicographically (instead of first appearance) so a given
306    /// level always gets the same palette colour and legend position across
307    /// charts, whatever order the data arrives in — no need to pre-sort levels
308    /// and repeat a manual palette per chart.
309    pub fn sorted(mut self) -> Self {
310        self.sorted = true;
311        self.resort();
312        self
313    }
314
315    fn resort(&mut self) {
316        if !self.sorted {
317            return;
318        }
319        self.levels.sort();
320        self.level_index = self
321            .levels
322            .iter()
323            .enumerate()
324            .map(|(i, l)| (l.clone(), i))
325            .collect();
326    }
327
328    pub fn with_palette(mut self, colors: Vec<RGBAColor>) -> Self {
329        self.palette = colors;
330        self.generated = None;
331        self
332    }
333
334    pub fn with_named_palette(mut self, name: &super::palettes::PaletteName) -> Self {
335        self.palette = super::palettes::palette(name).to_vec();
336        self.generated = None;
337        self
338    }
339
340    /// Pre-seed the factor levels (order), so each level keeps a fixed palette
341    /// color regardless of which levels are present in the data — e.g. for a
342    /// legend whose series can be toggled without the colors reshuffling.
343    pub fn with_levels(mut self, levels: Vec<String>) -> Self {
344        self.levels.clear();
345        self.level_index.clear();
346        for l in levels {
347            self.push_level(l);
348        }
349        self.resort();
350        self
351    }
352
353    fn push_level(&mut self, key: String) {
354        if !self.level_index.contains_key(&key) {
355            self.level_index.insert(key.clone(), self.levels.len());
356            self.levels.push(key);
357        }
358    }
359
360    fn level_position(&self, key: &str) -> Option<usize> {
361        self.level_index.get(key).copied()
362    }
363
364    /// Get color for a given level index.
365    pub fn color_for_index(&self, idx: usize) -> RGBAColor {
366        if let Some(g) = &self.generated {
367            return g.color_at(idx, self.levels.len().max(idx + 1));
368        }
369        if self.palette.is_empty() {
370            return DEFAULT_PALETTE[idx % DEFAULT_PALETTE.len()];
371        }
372        self.palette[idx % self.palette.len()]
373    }
374
375    /// Get color for a value.
376    pub fn color_for_value(&self, value: &Value) -> RGBAColor {
377        let key = value.key_str();
378        let idx = self.level_position(&key).unwrap_or(0);
379        self.color_for_index(idx)
380    }
381
382    pub fn levels(&self) -> &[String] {
383        &self.levels
384    }
385}
386
387impl Scale for ScaleColorDiscrete {
388    fn aesthetic(&self) -> Aesthetic {
389        self.aesthetic.clone()
390    }
391
392    fn train(&mut self, values: &[Value]) {
393        let before = self.levels.len();
394        for v in values {
395            let key = v.key_str();
396            if !self.level_index.contains_key(key.as_ref()) {
397                self.push_level(key.into_owned());
398            }
399        }
400        if self.levels.len() != before {
401            self.resort();
402        }
403    }
404
405    fn map(&self, value: &Value) -> f64 {
406        let key = value.key_str();
407        self.level_position(&key).map(|i| i as f64).unwrap_or(0.0)
408    }
409
410    fn breaks(&self) -> Vec<(f64, String)> {
411        self.levels
412            .iter()
413            .enumerate()
414            .map(|(i, label)| (i as f64, label.clone()))
415            .collect()
416    }
417
418    fn name(&self) -> &str {
419        &self.name
420    }
421
422    fn set_name(&mut self, name: &str) {
423        self.name = name.to_string();
424    }
425
426    fn is_discrete(&self) -> bool {
427        true
428    }
429
430    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
431        let c = self.color_for_value(value);
432        Some((c.r, c.g, c.b))
433    }
434
435    fn clone_box(&self) -> Box<dyn Scale> {
436        Box::new(self.clone())
437    }
438
439    fn reset_training(&mut self) {
440        self.levels.clear();
441        self.level_index.clear();
442    }
443}
444
445/// Continuous gradient color scale.
446#[derive(Clone)]
447pub struct ScaleColorContinuous {
448    aesthetic: Aesthetic,
449    name: String,
450    low: RGBAColor,
451    high: RGBAColor,
452    min: f64,
453    max: f64,
454    /// Locale for the default break labels (`None` = default marks).
455    locale: Option<crate::locale::Locale>,
456    /// Fixed `(lo, hi)` domain ([`with_limits`](Self::with_limits)):
457    /// `train` no longer widens it, and `map_to_color` maps against it
458    /// instead of the trained min/max.
459    limits: Option<(f64, f64)>,
460    /// Out-of-bounds policy for `limits` ([`with_oob`](Self::with_oob)).
461    /// Default [`Oob::Censor`].
462    oob: super::Oob,
463    /// Colour for a genuinely missing or out-of-bounds-and-censored value
464    /// ([`with_na_value`](Self::with_na_value)). Default ggplot2's
465    /// `"grey50"`, `(127, 127, 127)`.
466    na_value: (u8, u8, u8),
467    /// Formatter for [`format_value`](super::Scale::format_value) (#164,
468    /// same shape as `ScaleContinuous::with_value_formatter`), distinct
469    /// from the axis-break label formatting this scale has none of. Falls
470    /// back to the plain locale-formatted number when unset. Wrapped in
471    /// [`UnwindSafeFormatter`](super::format::UnwindSafeFormatter) so this
472    /// field doesn't drop `UnwindSafe`/`RefUnwindSafe` from the struct.
473    value_formatter: Option<super::format::UnwindSafeFormatter>,
474}
475
476// Manual `Debug`, since `value_formatter` (an `Arc<dyn Fn>`) isn't
477// `Debug`; every other field is printed as the derive would.
478impl std::fmt::Debug for ScaleColorContinuous {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        f.debug_struct("ScaleColorContinuous")
481            .field("aesthetic", &self.aesthetic)
482            .field("name", &self.name)
483            .field("low", &self.low)
484            .field("high", &self.high)
485            .field("min", &self.min)
486            .field("max", &self.max)
487            .field("locale", &self.locale)
488            .field("limits", &self.limits)
489            .field("oob", &self.oob)
490            .field("na_value", &self.na_value)
491            .field("value_formatter", &self.value_formatter.is_some())
492            .finish()
493    }
494}
495
496impl ScaleColorContinuous {
497    pub fn new(aesthetic: Aesthetic) -> Self {
498        ScaleColorContinuous {
499            aesthetic,
500            name: String::new(),
501            low: RGBAColor::new(0, 0, 255),
502            high: RGBAColor::new(255, 0, 0),
503            min: f64::INFINITY,
504            max: f64::NEG_INFINITY,
505            locale: None,
506            limits: None,
507            oob: super::Oob::Censor,
508            na_value: super::DEFAULT_NA_VALUE,
509            value_formatter: None,
510        }
511    }
512
513    /// Set a formatter for [`format_value`](super::Scale::format_value) —
514    /// a per-mark `data-fvalue`/tooltip-template value for this fill/colour
515    /// scale (#164). Falls back to the plain locale-formatted number when
516    /// unset (byte-identical default output).
517    pub fn with_value_formatter<F>(mut self, f: F) -> Self
518    where
519        F: Fn(f64) -> String + Send + Sync + 'static,
520    {
521        self.value_formatter = Some(super::format::UnwindSafeFormatter(std::sync::Arc::new(f)));
522        self
523    }
524
525    pub fn with_colors(mut self, low: RGBAColor, high: RGBAColor) -> Self {
526        self.low = low;
527        self.high = high;
528        self
529    }
530
531    /// Fix the colour domain to `[lo, hi]` — e.g. `(-1.0, 1.0)` for a
532    /// correlation matrix — instead of whatever the data happens to span.
533    /// Out-of-range values follow [`with_oob`](Self::with_oob) (default:
534    /// `na_value`, not the geom's own default colour).
535    pub fn with_limits(mut self, lo: f64, hi: f64) -> Self {
536        self.limits = Some((lo, hi));
537        self
538    }
539
540    /// Out-of-bounds policy for [`with_limits`](Self::with_limits):
541    /// [`Oob::Censor`] (default, ggplot2's own default) or
542    /// [`Oob::Squish`].
543    pub fn with_oob(mut self, oob: super::Oob) -> Self {
544        self.oob = oob;
545        self
546    }
547
548    /// Colour for a genuinely missing (`NA`) value, or one censored by
549    /// `with_limits`'s default [`Oob::Censor`]. Default ggplot2's
550    /// `"grey50"`, `(127, 127, 127)`.
551    pub fn with_na_value(mut self, color: (u8, u8, u8)) -> Self {
552        self.na_value = color;
553        self
554    }
555
556    pub fn color_at(&self, t: f64) -> RGBAColor {
557        self.low.lerp(&self.high, t)
558    }
559}
560
561impl Scale for ScaleColorContinuous {
562    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
563        if self.locale.is_none() {
564            self.locale = Some(locale.clone());
565        }
566    }
567
568    fn locale(&self) -> Option<&crate::locale::Locale> {
569        self.locale.as_ref()
570    }
571
572    fn format_value(&self, value: &Value) -> Option<String> {
573        let f = value.as_f64()?;
574        if !f.is_finite() {
575            return None;
576        }
577        Some(match &self.value_formatter {
578            Some(fmt) => fmt.call(f),
579            None => super::util::format_number_opt(f, self.locale.as_ref()),
580        })
581    }
582
583    fn aesthetic(&self) -> Aesthetic {
584        self.aesthetic.clone()
585    }
586
587    fn train(&mut self, values: &[Value]) {
588        if self.limits.is_some() {
589            return;
590        }
591        for v in values {
592            if let Some(f) = v.as_f64() {
593                if f.is_finite() {
594                    if f < self.min {
595                        self.min = f;
596                    }
597                    if f > self.max {
598                        self.max = f;
599                    }
600                }
601            }
602        }
603    }
604
605    fn map(&self, value: &Value) -> f64 {
606        let f = match value.as_f64() {
607            Some(f) => f,
608            None => return 0.0,
609        };
610        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
611        let range = hi - lo;
612        if range.abs() < f64::EPSILON {
613            0.5
614        } else {
615            (f - lo) / range
616        }
617    }
618
619    fn breaks(&self) -> Vec<(f64, String)> {
620        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
621        if lo > hi || !lo.is_finite() || !hi.is_finite() {
622            return vec![];
623        }
624
625        let range = hi - lo;
626        if super::util::is_degenerate_range(lo, hi) {
627            return vec![(
628                0.5,
629                super::util::format_number_opt(lo, self.locale.as_ref()),
630            )];
631        }
632
633        let n_breaks = 5;
634        let raw_step = range / n_breaks as f64;
635        let step = nice_step(raw_step);
636
637        let start = (lo / step).ceil() * step;
638        let values = super::util::stepped_breaks(start, hi, step);
639        let labels = super::util::format_numbers_opt(&values, self.locale.as_ref());
640        values
641            .iter()
642            .zip(labels)
643            .map(|(&v, l)| (self.map(&Value::Float(v)), l))
644            .collect()
645    }
646
647    fn name(&self) -> &str {
648        &self.name
649    }
650
651    fn set_name(&mut self, name: &str) {
652        self.name = name.to_string();
653    }
654
655    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
656        // A genuinely missing value, or a non-finite one (NaN/+-Inf),
657        // renders as `na_value` — ggplot2's own behaviour, and review
658        // PR#141 finding 3. A non-numeric-but-present value (e.g. the
659        // wrong column type) keeps the pre-existing contract of falling
660        // through to `map`'s own 0.0 fallback instead (this type's
661        // `map_to_color` always returned `Some` before `with_limits`/`Oob`
662        // existed, and nothing here can tell "wrong type" apart from
663        // "never had a numeric concept to begin with").
664        if value.is_na() {
665            return Some(self.na_value);
666        }
667        if let Some(f) = value.as_f64() {
668            if !f.is_finite() {
669                return Some(self.na_value);
670            }
671            if super::resolve_oob(f, self.limits, self.oob).is_none() {
672                return Some(self.na_value);
673            }
674        }
675        let t = self.map(value);
676        let c = self.color_at(t);
677        Some((c.r, c.g, c.b))
678    }
679
680    fn domain(&self) -> Option<(f64, f64)> {
681        if let Some((lo, hi)) = self.limits {
682            return Some((lo, hi));
683        }
684        if self.min.is_finite() && self.max.is_finite() && self.min <= self.max {
685            Some((self.min, self.max))
686        } else {
687            None
688        }
689    }
690
691    fn clone_box(&self) -> Box<dyn Scale> {
692        Box::new(self.clone())
693    }
694
695    fn reset_training(&mut self) {
696        self.min = f64::INFINITY;
697        self.max = f64::NEG_INFINITY;
698    }
699
700    fn validate(&self, warnings: &mut Vec<String>) {
701        if let Some((lo, hi)) = self.limits {
702            if lo > hi {
703                warnings.push(format!(
704                    "scale_color/fill_gradient: with_limits({lo}, {hi}) has lo > hi; every value maps to na_value"
705                ));
706            }
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use crate::aes::Aesthetic;
715
716    #[test]
717    fn continuous_format_value_rejects_non_finite() {
718        let s = ScaleColorContinuous::new(Aesthetic::Fill);
719        assert_eq!(s.format_value(&Value::Float(3.5)), Some("3.5".to_string()));
720        assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
721        assert_eq!(s.format_value(&Value::Na), None);
722    }
723
724    #[test]
725    fn continuous_with_value_formatter_overrides_format_value() {
726        let s =
727            ScaleColorContinuous::new(Aesthetic::Fill).with_value_formatter(|v| format!("${v:.2}"));
728        assert_eq!(
729            s.format_value(&Value::Float(1234.5)),
730            Some("$1234.50".to_string())
731        );
732        // Non-finite is still rejected before the formatter ever runs.
733        assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
734    }
735
736    #[test]
737    fn continuous_debug_reports_whether_a_formatter_is_set() {
738        let without = format!("{:?}", ScaleColorContinuous::new(Aesthetic::Fill));
739        assert!(without.contains("ScaleColorContinuous"));
740        assert!(without.contains("value_formatter: false"));
741        let with = format!(
742            "{:?}",
743            ScaleColorContinuous::new(Aesthetic::Fill).with_value_formatter(|v| v.to_string())
744        );
745        assert!(with.contains("value_formatter: true"));
746    }
747
748    /// #164 review: `value_formatter`'s `Arc<dyn Fn>` must not drop
749    /// `UnwindSafe`/`RefUnwindSafe` (or `Send`/`Sync`/`Clone`) from
750    /// `ScaleColorContinuous` — `cargo-semver-checks` flags losing an
751    /// auto trait as a major-version break, which a patch must not cause.
752    #[test]
753    fn scale_color_continuous_keeps_every_auto_trait() {
754        fn assert_traits<
755            T: Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe + Clone,
756        >() {
757        }
758        assert_traits::<ScaleColorContinuous>();
759    }
760}
761
762#[cfg(test)]
763mod hue_tests {
764    use super::*;
765
766    fn hex(c: RGBAColor) -> String {
767        format!("#{:02X}{:02X}{:02X}", c.r, c.g, c.b)
768    }
769
770    #[test]
771    fn hue_pal_matches_scales() {
772        // R: scales::hue_pal()(2), (5), and a custom one.
773        let d = HuePalette::default();
774        let got: Vec<String> = hue_palette(2, &d).into_iter().map(hex).collect();
775        assert_eq!(got, ["#F8766D", "#00BFC4"]);
776        let got: Vec<String> = hue_palette(5, &d).into_iter().map(hex).collect();
777        assert_eq!(got, ["#F8766D", "#A3A500", "#00BF7D", "#00B0F6", "#E76BF3"]);
778        let p = HuePalette {
779            h: (0.0, 180.0),
780            c: 50.0,
781            l: 80.0,
782            h_start: 30.0,
783            direction: -1,
784        };
785        let got: Vec<String> = hue_palette(4, &p).into_iter().map(hex).collect();
786        assert_eq!(got, ["#70D5E7", "#7BD9AD", "#C6CC82", "#F7B8A1"]);
787    }
788
789    #[test]
790    fn default_discrete_scale_is_hue_pal() {
791        // #408: the default discrete colour scale spreads ggplot2's
792        // hue_pal() over the trained number of levels.
793        let mut s = ScaleColorDiscrete::new(Aesthetic::Color);
794        s = s.with_levels(["a", "b", "c", "d", "e"].map(String::from).to_vec());
795        let got: Vec<String> = (0..5).map(|i| hex(s.color_for_index(i))).collect();
796        assert_eq!(got, ["#F8766D", "#A3A500", "#00BF7D", "#00B0F6", "#E76BF3"]);
797        // color_at agrees with colors() for a reversed palette.
798        let g = GeneratedPalette::Hue(HuePalette {
799            direction: -1,
800            ..HuePalette::default()
801        });
802        let all = g.colors(4);
803        assert!((0..4).all(|i| hex(g.color_at(i, 4)) == hex(all[i])));
804    }
805
806    #[test]
807    fn viridis_d_is_close_to_scales() {
808        // R: scales::viridis_pal(option = "D")(4); viridisLite splines in
809        // Lab space, we interpolate its 256-entry map linearly.
810        let want = [
811            (0x44, 0x01, 0x54),
812            (0x31, 0x68, 0x8E),
813            (0x35, 0xB7, 0x79),
814            (0xFD, 0xE7, 0x25),
815        ];
816        let got = GeneratedPalette::Viridis {
817            option: 'D',
818            begin: 0.0,
819            end: 1.0,
820            direction: 1,
821        }
822        .colors(4);
823        for (g, w) in got.iter().zip(want) {
824            for (a, b) in [(g.r, w.0), (g.g, w.1), (g.b, w.2)] {
825                assert!((a as i32 - b).abs() <= 1, "{} vs {w:?}", hex(*g));
826            }
827        }
828        // option A, begin .2, end .8, reversed: #FE9F6D #B63679 #3B0F70.
829        let want = [(0xFE, 0x9F, 0x6D), (0xB6, 0x36, 0x79), (0x3B, 0x0F, 0x70)];
830        let got = GeneratedPalette::Viridis {
831            option: 'A',
832            begin: 0.2,
833            end: 0.8,
834            direction: -1,
835        }
836        .colors(3);
837        for (g, w) in got.iter().zip(want) {
838            for (a, b) in [(g.r, w.0), (g.g, w.1), (g.b, w.2)] {
839                assert!((a as i32 - b).abs() <= 1, "{} vs {w:?}", hex(*g));
840            }
841        }
842    }
843}