Skip to main content

chartml_core/scales/
ordinal.rs

1/// Maps discrete domain values to discrete range values (typically colors).
2/// Equivalent to D3's `scaleOrdinal()`.
3pub struct ScaleOrdinal {
4    domain: Vec<String>,
5    range: Vec<String>,
6}
7
8impl ScaleOrdinal {
9    /// Create a new ordinal scale with the given domain and range.
10    pub fn new(domain: Vec<String>, range: Vec<String>) -> Self {
11        Self { domain, range }
12    }
13
14    /// Map a domain value to a range value.
15    /// Uses index-based lookup: domain[i] -> range[i % range.len()]
16    /// Returns None if the value is not in the domain or if the range is empty.
17    pub fn map(&self, value: &str) -> Option<&str> {
18        if self.range.is_empty() {
19            return None;
20        }
21        let index = self.domain.iter().position(|d| d == value)?;
22        Some(&self.range[index % self.range.len()])
23    }
24
25    /// Get the range value at index i (cycling if i >= range.len()).
26    /// Returns None if the range is empty.
27    pub fn map_index(&self, index: usize) -> Option<&str> {
28        if self.range.is_empty() {
29            return None;
30        }
31        Some(&self.range[index % self.range.len()])
32    }
33
34    /// Get the domain values.
35    pub fn domain(&self) -> &[String] {
36        &self.domain
37    }
38
39    /// Get the range values.
40    pub fn range(&self) -> &[String] {
41        &self.range
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    #![allow(clippy::unwrap_used)]
48    use super::*;
49
50    #[test]
51    fn ordinal_basic_mapping() {
52        let scale = ScaleOrdinal::new(
53            vec!["a".into(), "b".into(), "c".into()],
54            vec!["red".into(), "green".into(), "blue".into()],
55        );
56        assert_eq!(scale.map("a"), Some("red"));
57        assert_eq!(scale.map("b"), Some("green"));
58        assert_eq!(scale.map("c"), Some("blue"));
59    }
60
61    #[test]
62    fn ordinal_cycling() {
63        let scale = ScaleOrdinal::new(
64            vec!["a".into(), "b".into(), "c".into(), "d".into()],
65            vec!["red".into(), "green".into()],
66        );
67        assert_eq!(scale.map("a"), Some("red"));
68        assert_eq!(scale.map("b"), Some("green"));
69        assert_eq!(scale.map("c"), Some("red"));   // cycles back
70        assert_eq!(scale.map("d"), Some("green"));  // cycles back
71    }
72
73    #[test]
74    fn ordinal_unknown_returns_none() {
75        let scale = ScaleOrdinal::new(
76            vec!["a".into(), "b".into()],
77            vec!["red".into(), "green".into()],
78        );
79        assert_eq!(scale.map("unknown"), None);
80    }
81
82    #[test]
83    fn ordinal_empty_range() {
84        let scale = ScaleOrdinal::new(
85            vec!["a".into(), "b".into()],
86            vec![],
87        );
88        assert_eq!(scale.map("a"), None);
89    }
90
91    #[test]
92    fn ordinal_map_index() {
93        let scale = ScaleOrdinal::new(
94            vec!["a".into(), "b".into()],
95            vec!["red".into(), "green".into(), "blue".into()],
96        );
97        assert_eq!(scale.map_index(0), Some("red"));
98        assert_eq!(scale.map_index(1), Some("green"));
99        assert_eq!(scale.map_index(2), Some("blue"));
100        assert_eq!(scale.map_index(3), Some("red")); // cycles
101    }
102}