Skip to main content

benday_core/
theme.rs

1//! Color themes, named after print processes. Gradients and palettes are
2//! generated in OKLCH so ramps are perceptually uniform (no brightness wobble
3//! mid-gradient like naive HSV interpolation).
4
5use crate::raster::Rgb;
6
7#[derive(Debug, Clone, Copy)]
8pub struct Oklch {
9    pub l: f64,
10    pub c: f64,
11    /// Hue in degrees.
12    pub h: f64,
13}
14
15impl Oklch {
16    pub fn rgb(self) -> Rgb {
17        let hr = self.h.to_radians();
18        let (a, b) = (self.c * hr.cos(), self.c * hr.sin());
19        let l_ = self.l + 0.3963377774 * a + 0.2158037573 * b;
20        let m_ = self.l - 0.1055613458 * a - 0.0638541728 * b;
21        let s_ = self.l - 0.0894841775 * a - 1.2914855480 * b;
22        let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
23        let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
24        let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
25        let bb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
26        Rgb(gamma(r), gamma(g), gamma(bb))
27    }
28}
29
30fn gamma(c: f64) -> u8 {
31    let c = c.clamp(0.0, 1.0);
32    let v = if c <= 0.0031308 {
33        12.92 * c
34    } else {
35        1.055 * c.powf(1.0 / 2.4) - 0.055
36    };
37    (v * 255.0).round() as u8
38}
39
40fn mix(a: Oklch, b: Oklch, t: f64) -> Oklch {
41    let mut dh = b.h - a.h;
42    if dh > 180.0 {
43        dh -= 360.0;
44    }
45    if dh < -180.0 {
46        dh += 360.0;
47    }
48    Oklch {
49        l: a.l + (b.l - a.l) * t,
50        c: a.c + (b.c - a.c) * t,
51        h: a.h + dh * t,
52    }
53}
54
55#[derive(Debug, Clone)]
56pub struct Theme {
57    pub name: &'static str,
58    pub axis: Rgb,
59    pub title: Rgb,
60    /// Single-series accent color.
61    pub accent: Rgb,
62    grad_ends: (Oklch, Oklch),
63    pub palette: Vec<Rgb>,
64}
65
66impl Theme {
67    /// Sample the theme gradient at t in [0, 1].
68    pub fn grad(&self, t: f64) -> Rgb {
69        mix(self.grad_ends.0, self.grad_ends.1, t.clamp(0.0, 1.0)).rgb()
70    }
71
72    /// The i-th series color. Wraps past the palette end: safe for categorical
73    /// BARS (each bar is position-identified by its x label; color is
74    /// decoration) — xy callers are guarded by compile's palette cap, which
75    /// rejects charts where color alone must distinguish more series than the
76    /// palette holds.
77    pub fn series(&self, i: usize) -> Rgb {
78        self.palette[i % self.palette.len()]
79    }
80}
81
82pub const THEME_NAMES: &[&str] = &["benday", "lichtenstein", "rotogravure"];
83
84pub fn by_name(name: &str) -> Option<Theme> {
85    match name {
86        // House style: cool teal-to-amber thermal ramp, restrained axes.
87        "benday" => Some(Theme {
88            name: "benday",
89            axis: Rgb(106, 112, 122),
90            title: Rgb(222, 226, 232),
91            accent: Oklch {
92                l: 0.80,
93                c: 0.14,
94                h: 210.0,
95            }
96            .rgb(),
97            grad_ends: (
98                Oklch {
99                    l: 0.70,
100                    c: 0.12,
101                    h: 235.0,
102                },
103                Oklch {
104                    l: 0.86,
105                    c: 0.15,
106                    h: 75.0,
107                },
108            ),
109            palette: palette_from_hues(&[235.0, 155.0, 70.0, 330.0, 200.0, 20.0, 110.0, 285.0]),
110        }),
111        // Comic primaries: red, yellow, blue over warm gray.
112        "lichtenstein" => Some(Theme {
113            name: "lichtenstein",
114            axis: Rgb(132, 120, 106),
115            title: Rgb(235, 228, 216),
116            accent: Oklch {
117                l: 0.62,
118                c: 0.22,
119                h: 28.0,
120            }
121            .rgb(),
122            grad_ends: (
123                Oklch {
124                    l: 0.58,
125                    c: 0.22,
126                    h: 28.0,
127                },
128                Oklch {
129                    l: 0.88,
130                    c: 0.16,
131                    h: 95.0,
132                },
133            ),
134            palette: vec![
135                Oklch {
136                    l: 0.62,
137                    c: 0.22,
138                    h: 28.0,
139                }
140                .rgb(),
141                Oklch {
142                    l: 0.88,
143                    c: 0.16,
144                    h: 95.0,
145                }
146                .rgb(),
147                Oklch {
148                    l: 0.55,
149                    c: 0.19,
150                    h: 262.0,
151                }
152                .rgb(),
153                Oklch {
154                    l: 0.42,
155                    c: 0.08,
156                    h: 262.0,
157                }
158                .rgb(),
159            ],
160        }),
161        // Monochrome ink: lightness ramp, near-zero chroma.
162        "rotogravure" => Some(Theme {
163            name: "rotogravure",
164            axis: Rgb(110, 110, 116),
165            title: Rgb(228, 228, 232),
166            accent: Oklch {
167                l: 0.85,
168                c: 0.02,
169                h: 250.0,
170            }
171            .rgb(),
172            grad_ends: (
173                Oklch {
174                    l: 0.42,
175                    c: 0.02,
176                    h: 250.0,
177                },
178                Oklch {
179                    l: 0.95,
180                    c: 0.02,
181                    h: 250.0,
182                },
183            ),
184            palette: vec![
185                Oklch {
186                    l: 0.90,
187                    c: 0.02,
188                    h: 250.0,
189                }
190                .rgb(),
191                Oklch {
192                    l: 0.70,
193                    c: 0.02,
194                    h: 250.0,
195                }
196                .rgb(),
197                Oklch {
198                    l: 0.52,
199                    c: 0.02,
200                    h: 250.0,
201                }
202                .rgb(),
203                Oklch {
204                    l: 0.36,
205                    c: 0.02,
206                    h: 250.0,
207                }
208                .rgb(),
209            ],
210        }),
211        _ => None,
212    }
213}
214
215fn palette_from_hues(hues: &[f64]) -> Vec<Rgb> {
216    hues.iter()
217        .map(|&h| {
218            Oklch {
219                l: 0.78,
220                c: 0.13,
221                h,
222            }
223            .rgb()
224        })
225        .collect()
226}