Skip to main content

ggplot_rs/scale/
identity.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::Scale;
5
6/// Parse a `#RRGGBB` or `#RGB` hex colour string. Anything else (missing
7/// `#`, wrong length, non-hex digits) is `None`.
8fn parse_hex(s: &str) -> Option<(u8, u8, u8)> {
9    let s = s.strip_prefix('#')?;
10    let digit = |c: u8| -> Option<u8> {
11        match c {
12            b'0'..=b'9' => Some(c - b'0'),
13            b'a'..=b'f' => Some(c - b'a' + 10),
14            b'A'..=b'F' => Some(c - b'A' + 10),
15            _ => None,
16        }
17    };
18    let bytes = s.as_bytes();
19    match bytes.len() {
20        6 => {
21            let byte =
22                |i: usize| -> Option<u8> { Some(digit(bytes[i])? * 16 + digit(bytes[i + 1])?) };
23            Some((byte(0)?, byte(2)?, byte(4)?))
24        }
25        3 => {
26            let byte = |i: usize| -> Option<u8> {
27                let d = digit(bytes[i])?;
28                Some(d * 16 + d)
29            };
30            Some((byte(0)?, byte(1)?, byte(2)?))
31        }
32        _ => None,
33    }
34}
35
36/// Identity colour/fill scale (R's `scale_colour_identity`/
37/// `scale_fill_identity`): the data column already holds `#RRGGBB`/`#RGB`
38/// hex colour strings, used as-is. No legend (`breaks()` is empty). An
39/// invalid value maps to `None`, so the geom's own default colour applies.
40#[derive(Clone)]
41pub struct ScaleColorIdentity {
42    aesthetic: Aesthetic,
43}
44
45impl ScaleColorIdentity {
46    /// `aes` is [`Aesthetic::Color`] or [`Aesthetic::Fill`] — whichever
47    /// aesthetic this identity scale takes over.
48    pub fn new(aes: Aesthetic) -> Self {
49        ScaleColorIdentity { aesthetic: aes }
50    }
51}
52
53impl Scale for ScaleColorIdentity {
54    fn aesthetic(&self) -> Aesthetic {
55        self.aesthetic.clone()
56    }
57
58    fn train(&mut self, _values: &[Value]) {
59        // No domain to train: every value maps to its own literal colour.
60    }
61
62    fn map(&self, _value: &Value) -> f64 {
63        0.0
64    }
65
66    fn breaks(&self) -> Vec<(f64, String)> {
67        Vec::new()
68    }
69
70    fn name(&self) -> &str {
71        ""
72    }
73
74    fn set_name(&mut self, _name: &str) {}
75
76    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
77        value.as_str().and_then(parse_hex)
78    }
79
80    fn clone_box(&self) -> Box<dyn Scale> {
81        Box::new(self.clone())
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn parses_6_and_3_digit_hex() {
91        assert_eq!(parse_hex("#FF0000"), Some((255, 0, 0)));
92        assert_eq!(parse_hex("#00ff00"), Some((0, 255, 0)));
93        assert_eq!(parse_hex("#00F"), Some((0, 0, 255)));
94        assert_eq!(parse_hex("#abc"), Some((0xaa, 0xbb, 0xcc)));
95    }
96
97    #[test]
98    fn invalid_values_map_to_none() {
99        for bad in ["red", "#GGG", "#12345", "", "#"] {
100            assert_eq!(parse_hex(bad), None, "{bad} should not parse");
101        }
102        let scale = ScaleColorIdentity::new(Aesthetic::Color);
103        assert_eq!(scale.map_to_color(&Value::Str("not-a-colour".into())), None);
104        assert_eq!(scale.map_to_color(&Value::Float(1.0)), None);
105    }
106
107    #[test]
108    fn no_legend() {
109        let scale = ScaleColorIdentity::new(Aesthetic::Fill);
110        assert!(scale.breaks().is_empty());
111    }
112
113    /// `scale_color_identity()` (American spelling alias) and
114    /// `scale_colour_identity()` both apply to the `color` aesthetic.
115    #[test]
116    fn scale_color_identity_alias_applies_to_the_color_aesthetic() {
117        use crate::data::Value;
118        use crate::prelude::*;
119
120        let svg = GGPlot::new(vec![
121            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
122            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
123            (
124                "c".into(),
125                vec![Value::Str("#00FF00".into()), Value::Str("#0000FF".into())],
126            ),
127        ])
128        .aes(Aes::new().x("x").y("y").color("c"))
129        .geom_point()
130        .scale_color_identity()
131        .render_svg_native_with_size(200, 200)
132        .unwrap();
133
134        assert!(svg.contains("#00FF00"));
135        assert!(svg.contains("#0000FF"));
136    }
137
138    #[test]
139    fn renders_through_the_plot_with_no_legend_and_falls_back_on_invalid_values() {
140        use crate::data::Value;
141        use crate::prelude::*;
142
143        let svg = GGPlot::new(vec![
144            (
145                "x".into(),
146                vec![Value::Str("a".into()), Value::Str("b".into())],
147            ),
148            ("y".into(), vec![Value::Float(1.0), Value::Float(2.0)]),
149            (
150                "fill".into(),
151                vec![Value::Str("#FF0000".into()), Value::Str("bogus".into())],
152            ),
153        ])
154        .aes(Aes::new().x("x").y("y").fill("fill"))
155        .geom_col()
156        .scale_fill_identity()
157        .render_svg_native_with_size(200, 200)
158        .unwrap();
159
160        assert!(svg.contains("#FF0000"));
161        // No legend: the "fill" scale produces no breaks, so no legend title.
162        assert!(!svg.contains(">fill<"));
163    }
164
165    /// The identity-scale contract: no domain to train (ignores whatever
166    /// it's given), no name/legend breaks, `map` is a constant (it's never
167    /// actually used for positioning — only `map_to_color` matters), and
168    /// `clone_box` preserves which aesthetic it's for.
169    #[test]
170    fn scale_trait_contract_has_no_training_name_or_legend() {
171        let mut scale = ScaleColorIdentity::new(Aesthetic::Fill);
172        scale.train(&[Value::Str("#FF0000".into()), Value::Float(1.0)]);
173        assert_eq!(scale.name(), "");
174        scale.set_name("ignored");
175        assert_eq!(
176            scale.name(),
177            "",
178            "set_name is a no-op for an identity scale"
179        );
180        assert_eq!(scale.map(&Value::Str("#FF0000".into())), 0.0);
181        assert_eq!(scale.map(&Value::Na), 0.0);
182        assert!(scale.breaks().is_empty());
183
184        let cloned = scale.clone_box();
185        assert_eq!(cloned.aesthetic(), Aesthetic::Fill);
186        assert_eq!(
187            cloned.map_to_color(&Value::Str("#112233".into())),
188            Some((0x11, 0x22, 0x33))
189        );
190    }
191}