Skip to main content

esoc_color/
palette.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Color palettes for data visualization.
3
4use crate::oklab::OkLch;
5use crate::Color;
6
7/// An ordered list of colors for data visualization.
8#[derive(Clone, Debug)]
9pub struct Palette {
10    colors: Vec<Color>,
11}
12
13impl Palette {
14    /// Create a palette from a list of colors.
15    pub fn new(colors: Vec<Color>) -> Self {
16        assert!(!colors.is_empty(), "palette must have at least one color");
17        Self { colors }
18    }
19
20    /// Get the color at the given index, cycling if needed.
21    pub fn get(&self, index: usize) -> Color {
22        self.colors[index % self.colors.len()]
23    }
24
25    /// Number of colors.
26    pub fn len(&self) -> usize {
27        self.colors.len()
28    }
29
30    /// Whether the palette is empty.
31    pub fn is_empty(&self) -> bool {
32        self.colors.is_empty()
33    }
34
35    /// Iterate over colors.
36    pub fn iter(&self) -> impl Iterator<Item = &Color> {
37        self.colors.iter()
38    }
39
40    /// Interpolate across stops to get a color at `t` in `[0, 1]`.
41    pub fn sample(&self, t: f32) -> Color {
42        let t = t.clamp(0.0, 1.0);
43        if self.colors.len() == 1 {
44            return self.colors[0];
45        }
46        let max_idx = self.colors.len() - 1;
47        let scaled = t * max_idx as f32;
48        let lo = (scaled.floor() as usize).min(max_idx - 1);
49        let frac = scaled - lo as f32;
50        self.colors[lo].lerp_oklab(self.colors[lo + 1], frac)
51    }
52
53    // ── Built-in palettes ───────────────────────────────────────────
54
55    /// Tableau 10 categorical palette.
56    pub fn tab10() -> Self {
57        Self::new(vec![
58            Color::from_srgb8(0x1f, 0x77, 0xb4),
59            Color::from_srgb8(0xff, 0x7f, 0x0e),
60            Color::from_srgb8(0x2c, 0xa0, 0x2c),
61            Color::from_srgb8(0xd6, 0x27, 0x28),
62            Color::from_srgb8(0x94, 0x67, 0xbd),
63            Color::from_srgb8(0x8c, 0x56, 0x4b),
64            Color::from_srgb8(0xe3, 0x77, 0xc2),
65            Color::from_srgb8(0x7f, 0x7f, 0x7f),
66            Color::from_srgb8(0xbc, 0xbd, 0x22),
67            Color::from_srgb8(0x17, 0xbe, 0xcf),
68        ])
69    }
70
71    /// Viridis sequential palette (5 key stops).
72    pub fn viridis() -> Self {
73        Self::new(vec![
74            Color::from_srgb8(0x44, 0x01, 0x54),
75            Color::from_srgb8(0x31, 0x68, 0x8e),
76            Color::from_srgb8(0x35, 0xb7, 0x79),
77            Color::from_srgb8(0x90, 0xd7, 0x43),
78            Color::from_srgb8(0xfd, 0xe7, 0x25),
79        ])
80    }
81
82    /// Red-Blue diverging palette.
83    pub fn rdbu() -> Self {
84        Self::new(vec![
85            Color::from_srgb8(0xb2, 0x18, 0x2b),
86            Color::from_srgb8(0xef, 0x8a, 0x62),
87            Color::from_srgb8(0xf7, 0xf7, 0xf7),
88            Color::from_srgb8(0x67, 0xa9, 0xcf),
89            Color::from_srgb8(0x21, 0x66, 0xac),
90        ])
91    }
92
93    /// Generate a sequential palette by interpolating in `OKLab`.
94    pub fn sequential(start: Color, end: Color, n: usize) -> Self {
95        let n = n.max(2);
96        let colors = (0..n)
97            .map(|i| start.lerp_oklab(end, i as f32 / (n - 1) as f32))
98            .collect();
99        Self::new(colors)
100    }
101
102    /// Generate a diverging palette with a neutral midpoint.
103    pub fn diverging(low: Color, mid: Color, high: Color, n: usize) -> Self {
104        let n = n.max(3) | 1; // force odd
105        let half = n / 2;
106        let mut colors = Vec::with_capacity(n);
107        for i in 0..half {
108            colors.push(low.lerp_oklab(mid, i as f32 / half as f32));
109        }
110        colors.push(mid);
111        for i in 1..=half {
112            colors.push(mid.lerp_oklab(high, i as f32 / half as f32));
113        }
114        Self::new(colors)
115    }
116
117    /// Generate `n` evenly-spaced categorical colors in OKLCH.
118    pub fn categorical(n: usize) -> Self {
119        let n = n.max(1);
120        let colors = (0..n)
121            .map(|i| {
122                let lch = OkLch::new(0.7, 0.15, (i as f32 / n as f32) * 360.0);
123                Color::from_oklch(lch)
124            })
125            .collect();
126        Self::new(colors)
127    }
128}
129
130impl Default for Palette {
131    fn default() -> Self {
132        Self::tab10()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn tab10_has_10_colors() {
142        assert_eq!(Palette::tab10().len(), 10);
143    }
144
145    #[test]
146    fn sample_endpoints() {
147        let p = Palette::viridis();
148        let first = p.sample(0.0);
149        let last = p.sample(1.0);
150        assert!((first.r - p.get(0).r).abs() < 1e-3);
151        let end = p.get(p.len() - 1);
152        assert!((last.r - end.r).abs() < 0.05);
153    }
154
155    #[test]
156    fn categorical_distinct() {
157        let p = Palette::categorical(6);
158        assert_eq!(p.len(), 6);
159        // Colors should all be different
160        for i in 0..5 {
161            let a = p.get(i);
162            let b = p.get(i + 1);
163            let diff = (a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs();
164            assert!(diff > 0.01, "colors {i} and {} are too similar", i + 1);
165        }
166    }
167
168    #[test]
169    fn cycling() {
170        let p = Palette::tab10();
171        assert_eq!(p.get(0).to_hex(), p.get(10).to_hex());
172    }
173}