Skip to main content

animato_js/
color.rs

1//! Color interpolation bindings.
2
3use crate::easing::parse_easing;
4use crate::error::{JsResult, js_error, non_negative};
5use crate::types::{f32_array, normalize_name};
6use animato_core::Update;
7use animato_tween::Tween as CoreTween;
8use js_sys::Float32Array;
9use wasm_bindgen::prelude::*;
10
11#[derive(Clone, Copy, Debug, PartialEq)]
12struct Rgb {
13    r: f32,
14    g: f32,
15    b: f32,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19enum ColorSpace {
20    Rgb,
21    Linear,
22    Lab,
23    Oklch,
24}
25
26impl ColorSpace {
27    fn parse(input: &str) -> JsResult<Self> {
28        match normalize_name(input).as_str() {
29            "rgb" | "srgb" => Ok(Self::Rgb),
30            "linear" | "linearrgb" => Ok(Self::Linear),
31            "lab" => Ok(Self::Lab),
32            "oklch" | "oklab" => Ok(Self::Oklch),
33            _ => Err(js_error(format!("unknown color space `{input}`"))),
34        }
35    }
36}
37
38/// Perceptual color tween.
39#[wasm_bindgen(js_name = ColorTween)]
40#[derive(Clone, Debug)]
41pub struct ColorTween {
42    start: Rgb,
43    end: Rgb,
44    space: ColorSpace,
45    tween: CoreTween<f32>,
46}
47
48#[wasm_bindgen(js_class = ColorTween)]
49impl ColorTween {
50    /// Create a color tween from hex colors.
51    #[wasm_bindgen(constructor)]
52    pub fn new(from: &str, to: &str, duration: f32, space: &str) -> Result<Self, JsValue> {
53        Ok(Self {
54            start: parse_color(from)?,
55            end: parse_color(to)?,
56            space: ColorSpace::parse(space)?,
57            tween: CoreTween::new(0.0, 1.0)
58                .duration(non_negative(duration, 1.0))
59                .build(),
60        })
61    }
62
63    /// Advance by `dt` seconds.
64    pub fn update(&mut self, dt: f32) -> bool {
65        self.tween.update(dt)
66    }
67
68    /// Current color as hex.
69    #[wasm_bindgen(js_name = valueHex)]
70    pub fn value_hex(&self) -> String {
71        to_hex(interpolate(
72            self.start,
73            self.end,
74            self.tween.value(),
75            self.space,
76        ))
77    }
78
79    /// Current color as `[r, g, b, a]` in 0-1 floats.
80    #[wasm_bindgen(js_name = rgbaArray)]
81    pub fn rgba_array(&self) -> Float32Array {
82        let color = interpolate(self.start, self.end, self.tween.value(), self.space);
83        f32_array(&[color.r, color.g, color.b, 1.0])
84    }
85
86    /// Current normalized progress.
87    pub fn progress(&self) -> f32 {
88        self.tween.progress()
89    }
90
91    /// Whether playback is complete.
92    #[wasm_bindgen(js_name = isComplete)]
93    pub fn is_complete(&self) -> bool {
94        self.tween.is_complete()
95    }
96
97    /// Reset playback.
98    pub fn reset(&mut self) {
99        self.tween.reset();
100    }
101
102    /// Set easing by name.
103    #[wasm_bindgen(js_name = setEasing)]
104    pub fn set_easing(&mut self, easing: &str) -> Result<(), JsValue> {
105        self.tween.easing = parse_easing(easing)?;
106        Ok(())
107    }
108}
109
110/// Interpolate two colors immediately.
111#[wasm_bindgen(js_name = interpolateColor)]
112pub fn interpolate_color(from: &str, to: &str, t: f32, space: &str) -> Result<String, JsValue> {
113    Ok(to_hex(interpolate(
114        parse_color(from)?,
115        parse_color(to)?,
116        t,
117        ColorSpace::parse(space)?,
118    )))
119}
120
121fn interpolate(from: Rgb, to: Rgb, t: f32, space: ColorSpace) -> Rgb {
122    let t = t.clamp(0.0, 1.0);
123    match space {
124        ColorSpace::Rgb => lerp_rgb(from, to, t),
125        ColorSpace::Linear => linear_to_srgb(lerp_rgb(srgb_to_linear(from), srgb_to_linear(to), t)),
126        ColorSpace::Lab => xyz_to_srgb(lab_to_xyz(lerp3(srgb_to_lab(from), srgb_to_lab(to), t))),
127        ColorSpace::Oklch => oklab_to_srgb(oklch_to_oklab(lerp_oklch(
128            oklab_to_oklch(srgb_to_oklab(from)),
129            oklab_to_oklch(srgb_to_oklab(to)),
130            t,
131        ))),
132    }
133}
134
135fn lerp_rgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
136    Rgb {
137        r: a.r + (b.r - a.r) * t,
138        g: a.g + (b.g - a.g) * t,
139        b: a.b + (b.b - a.b) * t,
140    }
141}
142
143fn parse_color(input: &str) -> JsResult<Rgb> {
144    let hex = input.trim().trim_start_matches('#');
145    let (r, g, b) = match hex.len() {
146        3 => (
147            u8::from_str_radix(&hex[0..1].repeat(2), 16),
148            u8::from_str_radix(&hex[1..2].repeat(2), 16),
149            u8::from_str_radix(&hex[2..3].repeat(2), 16),
150        ),
151        6 => (
152            u8::from_str_radix(&hex[0..2], 16),
153            u8::from_str_radix(&hex[2..4], 16),
154            u8::from_str_radix(&hex[4..6], 16),
155        ),
156        _ => {
157            return Err(js_error(format!(
158                "expected #rgb or #rrggbb color, got `{input}`"
159            )));
160        }
161    };
162    Ok(Rgb {
163        r: r.map_err(|_| js_error(format!("invalid red channel in `{input}`")))? as f32 / 255.0,
164        g: g.map_err(|_| js_error(format!("invalid green channel in `{input}`")))? as f32 / 255.0,
165        b: b.map_err(|_| js_error(format!("invalid blue channel in `{input}`")))? as f32 / 255.0,
166    })
167}
168
169fn to_hex(color: Rgb) -> String {
170    format!(
171        "#{:02x}{:02x}{:02x}",
172        channel(color.r),
173        channel(color.g),
174        channel(color.b)
175    )
176}
177
178fn channel(value: f32) -> u8 {
179    (value.clamp(0.0, 1.0) * 255.0).round() as u8
180}
181
182fn srgb_channel_to_linear(c: f32) -> f32 {
183    if c <= 0.04045 {
184        c / 12.92
185    } else {
186        ((c + 0.055) / 1.055).powf(2.4)
187    }
188}
189
190fn linear_channel_to_srgb(c: f32) -> f32 {
191    if c <= 0.0031308 {
192        c * 12.92
193    } else {
194        1.055 * c.powf(1.0 / 2.4) - 0.055
195    }
196}
197
198fn srgb_to_linear(c: Rgb) -> Rgb {
199    Rgb {
200        r: srgb_channel_to_linear(c.r),
201        g: srgb_channel_to_linear(c.g),
202        b: srgb_channel_to_linear(c.b),
203    }
204}
205
206fn linear_to_srgb(c: Rgb) -> Rgb {
207    Rgb {
208        r: linear_channel_to_srgb(c.r),
209        g: linear_channel_to_srgb(c.g),
210        b: linear_channel_to_srgb(c.b),
211    }
212}
213
214fn srgb_to_xyz(c: Rgb) -> [f32; 3] {
215    let c = srgb_to_linear(c);
216    [
217        c.r * 0.4124564 + c.g * 0.3575761 + c.b * 0.1804375,
218        c.r * 0.2126729 + c.g * 0.7151522 + c.b * 0.0721750,
219        c.r * 0.0193339 + c.g * 0.119_192 + c.b * 0.9503041,
220    ]
221}
222
223fn xyz_to_srgb(xyz: [f32; 3]) -> Rgb {
224    linear_to_srgb(Rgb {
225        r: xyz[0] * 3.2404542 + xyz[1] * -1.5371385 + xyz[2] * -0.4985314,
226        g: xyz[0] * -0.969_266 + xyz[1] * 1.8760108 + xyz[2] * 0.0415560,
227        b: xyz[0] * 0.0556434 + xyz[1] * -0.2040259 + xyz[2] * 1.0572252,
228    })
229}
230
231fn srgb_to_lab(c: Rgb) -> [f32; 3] {
232    let xyz = srgb_to_xyz(c);
233    let x = lab_f(xyz[0] / 0.95047);
234    let y = lab_f(xyz[1]);
235    let z = lab_f(xyz[2] / 1.08883);
236    [116.0 * y - 16.0, 500.0 * (x - y), 200.0 * (y - z)]
237}
238
239fn lab_to_xyz(lab: [f32; 3]) -> [f32; 3] {
240    let y = (lab[0] + 16.0) / 116.0;
241    let x = lab[1] / 500.0 + y;
242    let z = y - lab[2] / 200.0;
243    [0.95047 * lab_f_inv(x), lab_f_inv(y), 1.08883 * lab_f_inv(z)]
244}
245
246fn lab_f(t: f32) -> f32 {
247    if t > 0.008856 {
248        t.cbrt()
249    } else {
250        7.787 * t + 16.0 / 116.0
251    }
252}
253
254fn lab_f_inv(t: f32) -> f32 {
255    let t3 = t * t * t;
256    if t3 > 0.008856 {
257        t3
258    } else {
259        (t - 16.0 / 116.0) / 7.787
260    }
261}
262
263fn srgb_to_oklab(c: Rgb) -> [f32; 3] {
264    let c = srgb_to_linear(c);
265    let l = 0.41222146 * c.r + 0.53633255 * c.g + 0.051445995 * c.b;
266    let m = 0.2119035 * c.r + 0.6806995 * c.g + 0.10739696 * c.b;
267    let s = 0.08830246 * c.r + 0.28171884 * c.g + 0.6299787 * c.b;
268    let l = l.cbrt();
269    let m = m.cbrt();
270    let s = s.cbrt();
271    [
272        0.21045426 * l + 0.7936178 * m - 0.004072047 * s,
273        1.9779985 * l - 2.4285922 * m + 0.4505937 * s,
274        0.025904037 * l + 0.78277177 * m - 0.80867577 * s,
275    ]
276}
277
278fn oklab_to_srgb(oklab: [f32; 3]) -> Rgb {
279    let l = oklab[0] + 0.39633778 * oklab[1] + 0.21580376 * oklab[2];
280    let m = oklab[0] - 0.105561346 * oklab[1] - 0.06385417 * oklab[2];
281    let s = oklab[0] - 0.08948418 * oklab[1] - 1.2914855 * oklab[2];
282    let l = l * l * l;
283    let m = m * m * m;
284    let s = s * s * s;
285    linear_to_srgb(Rgb {
286        r: 4.0767417 * l - 3.3077116 * m + 0.23096994 * s,
287        g: -1.268438 * l + 2.6097574 * m - 0.34131938 * s,
288        b: -0.0041960863 * l - 0.7034186 * m + 1.7076147 * s,
289    })
290}
291
292fn oklab_to_oklch(oklab: [f32; 3]) -> [f32; 3] {
293    [
294        oklab[0],
295        (oklab[1] * oklab[1] + oklab[2] * oklab[2]).sqrt(),
296        oklab[2].atan2(oklab[1]),
297    ]
298}
299
300fn oklch_to_oklab(oklch: [f32; 3]) -> [f32; 3] {
301    [
302        oklch[0],
303        oklch[1] * oklch[2].cos(),
304        oklch[1] * oklch[2].sin(),
305    ]
306}
307
308fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
309    [
310        a[0] + (b[0] - a[0]) * t,
311        a[1] + (b[1] - a[1]) * t,
312        a[2] + (b[2] - a[2]) * t,
313    ]
314}
315
316fn lerp_oklch(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
317    let mut dh = b[2] - a[2];
318    if dh > core::f32::consts::PI {
319        dh -= core::f32::consts::TAU;
320    } else if dh < -core::f32::consts::PI {
321        dh += core::f32::consts::TAU;
322    }
323    [
324        a[0] + (b[0] - a[0]) * t,
325        a[1] + (b[1] - a[1]) * t,
326        a[2] + dh * t,
327    ]
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn parses_hex() {
336        assert_eq!(to_hex(parse_color("#0f0").unwrap()), "#00ff00");
337    }
338
339    #[test]
340    fn interpolates_rgb() {
341        assert_eq!(
342            interpolate_color("#000", "#fff", 0.5, "rgb").unwrap(),
343            "#808080"
344        );
345    }
346}