Skip to main content

material_colors/
temperature.rs

1#[cfg(all(not(feature = "std"), feature = "libm"))]
2#[allow(unused_imports)]
3use crate::utils::no_std::FloatExt;
4use crate::{
5    color::{Argb, Lab},
6    hct::Hct,
7    utils::math::sanitize_degrees_double,
8    Map,
9};
10#[cfg(not(feature = "std"))]
11use alloc::{vec, vec::Vec};
12use core::cmp::Ordering;
13#[cfg(feature = "std")]
14use std::{vec, vec::Vec};
15
16/// Design utilities using color temperature theory.
17///
18/// Analogous colors, complementary color, and cache to efficiently, lazily,
19/// generate data for calculations when needed.
20pub struct TemperatureCache {
21    input: Hct,
22
23    _hcts_by_temp: Vec<Hct>,
24    _hcts_by_hue: Vec<Hct>,
25    _temps_by_hct: Map<Hct, f64>,
26    _input_relative_temperature: f64,
27    _complement: Option<Hct>,
28}
29
30impl TemperatureCache {
31    /// # Panics
32    ///
33    /// Will panic if there is no warmest HCT
34    pub fn warmest(&mut self) -> Hct {
35        let hcts = self.hcts_by_temp();
36
37        return *hcts.last().unwrap();
38    }
39
40    /// # Panics
41    ///
42    /// Will panic if there is no coldest HCT
43    pub fn coldest(&mut self) -> Hct {
44        let hcts = self.hcts_by_temp();
45
46        return *hcts.first().unwrap();
47    }
48
49    pub fn new(input: Hct) -> Self {
50        Self {
51            input,
52            _hcts_by_temp: vec![],
53            _hcts_by_hue: vec![],
54            _temps_by_hct: Map::default(),
55            _input_relative_temperature: -1.0,
56            _complement: None,
57        }
58    }
59
60    /// A set of colors with differing hues, equidistant in temperature.
61    ///
62    /// In art, this is usually described as a set of 5 colors on a color wheel
63    /// divided into 12 sections. This method allows provision of either of those
64    /// values.
65    ///
66    /// Behavior is undefined when `count` or `divisions` is 0.
67    /// When `divisions` < `count`, colors repeat.
68    ///
69    /// - `count`: The number of colors to return, includes the input color.
70    /// - `divisions`: The number of divisions on the color wheel.
71    pub fn analogous(&mut self, count: Option<i32>, divisions: Option<i32>) -> Vec<Hct> {
72        let count = count.unwrap_or(5);
73        let divisions = divisions.unwrap_or(12);
74        let start_hue = self.input.get_hue().round() as i32;
75        let start_hct = &self.hcts_by_hue()[start_hue as usize];
76        let mut last_temp = self.relative_temperature(start_hct);
77        let mut all_colors = vec![*start_hct];
78
79        let mut absolute_total_temp_delta = 0.0;
80
81        for i in 0..360 {
82            let hue = sanitize_degrees_double((start_hue + i).into());
83            let hct = &self.hcts_by_hue()[hue as usize];
84            let temp = self.relative_temperature(hct);
85            let temp_delta = (temp - last_temp).abs();
86
87            last_temp = temp;
88            absolute_total_temp_delta += temp_delta;
89        }
90
91        let mut hue_addend = 1;
92        let temp_step = absolute_total_temp_delta / f64::from(divisions);
93
94        let mut total_temp_delta = 0.0;
95
96        last_temp = self.relative_temperature(start_hct);
97
98        while all_colors.len() < divisions as usize {
99            let hue = sanitize_degrees_double((start_hue + hue_addend).into());
100            let hct = &self.hcts_by_hue()[hue as usize];
101            let temp = self.relative_temperature(hct);
102            let temp_delta = (temp - last_temp).abs();
103
104            total_temp_delta += temp_delta;
105
106            let desired_total_temp_delta_for_index = all_colors.len() as f64 * temp_step;
107
108            let mut index_satisfied = total_temp_delta >= desired_total_temp_delta_for_index;
109            let mut index_addend = 1;
110
111            // Keep adding this hue to the answers until its temperature is
112            // insufficient. This ensures consistent behavior when there aren't
113            // [divisions] discrete steps between 0 and 360 in hue with [tempStep]
114            // delta in temperature between them.
115            //
116            // For example, white and black have no analogues: there are no other
117            // colors at T100/T0. Therefore, they should just be added to the array
118            // as answers.
119            while index_satisfied && all_colors.len() < divisions as usize {
120                all_colors.push(*hct);
121
122                let desired_total_temp_delta_for_index =
123                    (all_colors.len() + index_addend) as f64 * temp_step;
124
125                index_satisfied = total_temp_delta >= desired_total_temp_delta_for_index;
126                index_addend += 1;
127            }
128
129            last_temp = temp;
130            hue_addend += 1;
131
132            if hue_addend > 360 {
133                while all_colors.len() < divisions as usize {
134                    all_colors.push(*hct);
135                }
136
137                break;
138            }
139        }
140
141        let mut answers = vec![self.input];
142
143        // First, generate analogues from rotating counter-clockwise.
144        let increase_hue_count = ((f64::from(count) - 1.0) / 2.0).floor() as isize;
145
146        for i in 1..=increase_hue_count {
147            let mut index = 0_isize - i;
148
149            while index < 0 {
150                index += all_colors.len() as isize;
151            }
152
153            if index >= all_colors.len() as isize {
154                index %= all_colors.len() as isize;
155            }
156
157            answers.insert(0, all_colors[index as usize]);
158        }
159
160        // Second, generate analogues from rotating clockwise.
161        let decrease_hue_count = (count - (increase_hue_count as i32) - 1) as isize;
162
163        for i in 1..=decrease_hue_count {
164            let mut index = i;
165
166            while index < 0 {
167                index += all_colors.len() as isize;
168            }
169
170            if index >= all_colors.len() as isize {
171                index %= all_colors.len() as isize;
172            }
173
174            answers.push(all_colors[index as usize]);
175        }
176
177        answers
178    }
179
180    /// A color that complements the input color aesthetically.
181    ///
182    /// In art, this is usually described as being across the color wheel.
183    /// History of this shows intent as a color that is just as cool-warm as the
184    /// input color is warm-cool.
185    ///
186    /// # Panics
187    ///
188    /// Will panic if there is no coldest or warmest HCT
189    pub fn complement(&mut self) -> Hct {
190        if let Some(_complement) = &self._complement {
191            return *_complement;
192        }
193
194        let coldest_hct = self.coldest();
195        let warmest_hct = self.warmest();
196
197        let temps_by_hct = self.temps_by_hct();
198
199        let coldest_hue = coldest_hct.get_hue();
200        let coldest_temp = temps_by_hct[&coldest_hct];
201
202        let warmest_hue = warmest_hct.get_hue();
203        let warmest_temp = temps_by_hct[&warmest_hct];
204
205        let range = warmest_temp - coldest_temp;
206        let start_hue_is_coldest_to_warmest =
207            Self::is_between(self.input.get_hue(), coldest_hue, warmest_hue);
208        let start_hue = if start_hue_is_coldest_to_warmest {
209            warmest_hue
210        } else {
211            coldest_hue
212        };
213        let end_hue = if start_hue_is_coldest_to_warmest {
214            coldest_hue
215        } else {
216            warmest_hue
217        };
218        let direction_of_rotation = 1.0_f64;
219        let mut smallest_error = 1000.0;
220        let mut answer = self.hcts_by_hue()[self.input.get_hue().round() as usize];
221
222        let complement_relative_temp = 1.0 - self.input_relative_temperature();
223
224        // Find the color in the other section, closest to the inverse percentile
225        // of the input color. This is the complement.
226        for hue_addend in 0..=360 {
227            let hue = sanitize_degrees_double(
228                direction_of_rotation.mul_add(f64::from(hue_addend), start_hue),
229            );
230
231            if !Self::is_between(hue, start_hue, end_hue) {
232                continue;
233            }
234
235            let possible_answer = &self.hcts_by_hue()[hue.round() as usize];
236            let relative_temp = (self._temps_by_hct[possible_answer] - coldest_temp) / range;
237            let error = (complement_relative_temp - relative_temp).abs();
238
239            if error < smallest_error {
240                smallest_error = error;
241                answer = *possible_answer;
242            }
243        }
244
245        self._complement = Some(answer);
246
247        self._complement.unwrap()
248    }
249
250    /// Temperature relative to all colors with the same chroma and tone.
251    /// Value on a scale from 0 to 1.
252    pub fn relative_temperature(&mut self, hct: &Hct) -> f64 {
253        let coldest = self.coldest();
254        let warmest = self.warmest();
255
256        let temps_by_hct = self.temps_by_hct();
257
258        let range = temps_by_hct[&warmest] - temps_by_hct[&coldest];
259        let difference_from_coldest = temps_by_hct[hct] - temps_by_hct[&coldest];
260
261        // Handle when there's no difference in temperature between warmest and
262        // coldest: for example, at T100, only one color is available, white.
263        if range == 0.0 {
264            return 0.5;
265        }
266
267        difference_from_coldest / range
268    }
269
270    /// Relative temperature of the input color. See [`relative_temperature`].
271    ///
272    /// [`relative_temperature`]: Self::relative_temperature
273    pub fn input_relative_temperature(&mut self) -> f64 {
274        if self._input_relative_temperature >= 0.0 {
275            return self._input_relative_temperature;
276        }
277
278        let coldest = self.coldest();
279        let warmest = self.warmest();
280        let input = self.input;
281
282        let temps_by_hct = self.temps_by_hct();
283
284        let coldest_temp = temps_by_hct[&coldest];
285
286        let range = temps_by_hct[&warmest] - coldest_temp;
287        let difference_from_coldest = temps_by_hct[&input] - coldest_temp;
288        let input_relative_temp = if range == 0.0 {
289            0.5
290        } else {
291            difference_from_coldest / range
292        };
293
294        self._input_relative_temperature = input_relative_temp;
295
296        self._input_relative_temperature
297    }
298
299    /// HCTs for all hues, with the same chroma/tone as the input.
300    /// Sorted from coldest first to warmest last.
301    pub fn hcts_by_temp(&mut self) -> &[Hct] {
302        if !self._hcts_by_temp.is_empty() {
303            return &self._hcts_by_temp;
304        }
305
306        let mut hcts = self.hcts_by_hue();
307
308        hcts.push(self.input);
309        hcts.sort_by(|a, b| self.sort_by_temp(a, b));
310
311        self._hcts_by_temp = hcts;
312
313        &self._hcts_by_temp
314    }
315
316    fn sort_by_temp(&mut self, this: &Hct, that: &Hct) -> Ordering {
317        let a = self.temps_by_hct()[this];
318        let b = self.temps_by_hct()[that];
319
320        a.partial_cmp(&b).unwrap()
321    }
322
323    /// A Map with keys of HCTs in `hcts_by_temp`, values of raw temperature.
324    pub fn temps_by_hct(&mut self) -> &Map<Hct, f64> {
325        if !self._temps_by_hct.is_empty() {
326            return &self._temps_by_hct;
327        }
328
329        let mut all_hcts = self.hcts_by_hue();
330
331        all_hcts.push(self.input);
332
333        let mut temperatures_by_hct = Map::default();
334
335        for e in all_hcts {
336            temperatures_by_hct.insert(e, Self::raw_temperature(e));
337        }
338
339        self._temps_by_hct = temperatures_by_hct;
340
341        &self._temps_by_hct
342    }
343
344    /// HCTs for all hues, with the same chroma/tone as the input.
345    /// Sorted ascending, hue 0 to 360.
346    pub fn hcts_by_hue(&mut self) -> Vec<Hct> {
347        if !self._hcts_by_hue.is_empty() {
348            return self._hcts_by_hue.clone();
349        }
350
351        let mut hcts = vec![];
352
353        for hue in 0..=360 {
354            let color_at_hue = Hct::from(
355                f64::from(hue),
356                self.input.get_chroma(),
357                self.input.get_tone(),
358            );
359
360            hcts.push(color_at_hue);
361        }
362
363        self._hcts_by_hue = hcts;
364
365        self._hcts_by_hue.clone()
366    }
367
368    /// Determines if an angle is between two other angles, rotating clockwise.
369    pub fn is_between(angle: f64, a: f64, b: f64) -> bool {
370        if a < b {
371            a <= angle && angle <= b
372        } else {
373            a <= angle || angle <= b
374        }
375    }
376
377    /// Value representing cool-warm factor of a color.
378    /// Values below 0 are considered cool, above, warm.
379    ///
380    /// Color science has researched emotion and harmony, which art uses to select
381    /// colors. Warm-cool is the foundation of analogous and complementary colors.
382    /// See:
383    /// - Li-Chen Ou's Chapter 19 in Handbook of Color Psychology (2015).
384    /// - Josef Albers' Interaction of Color chapters 19 and 21.
385    ///
386    /// Implementation of Ou, Woodcock and Wright's algorithm, which uses
387    /// L*a*b*/LCH color space.
388    /// Return value has these properties:
389    /// - Values below 0 are cool, above 0 are warm.
390    /// - Lower bound: -0.52 - (chroma ^ 1.07 / 20). L*a*b* chroma is infinite.
391    ///   Assuming max of 130 chroma, -9.66.
392    /// - Upper bound: -0.52 + (chroma ^ 1.07 / 20). L*a*b* chroma is infinite.
393    ///   Assuming max of 130 chroma, 8.61.
394    pub fn raw_temperature(color: Hct) -> f64 {
395        let lab = Lab::from(Argb::from(color));
396        let hue = sanitize_degrees_double(lab.b.atan2(lab.a).to_degrees());
397
398        let chroma = lab.a.hypot(lab.b);
399
400        (0.02 * chroma.powf(1.07)).mul_add(
401            (sanitize_degrees_double(hue - 50.0).to_radians()).cos(),
402            -0.5,
403        )
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::TemperatureCache;
410    use crate::{color::Argb, hct::Hct};
411    use float_cmp::assert_approx_eq;
412
413    #[test]
414    fn test_raw_temperature() {
415        let blue_hct = Hct::new(Argb::from_u32(0xff0000ff));
416        let red_hct = Hct::new(Argb::from_u32(0xffff0000));
417        let green_hct = Hct::new(Argb::from_u32(0xff00ff00));
418        let white_hct = Hct::new(Argb::from_u32(0xffffffff));
419        let black_hct = Hct::new(Argb::from_u32(0xff000000));
420
421        let blue_temp = TemperatureCache::raw_temperature(blue_hct);
422        let red_temp = TemperatureCache::raw_temperature(red_hct);
423        let green_temp = TemperatureCache::raw_temperature(green_hct);
424        let white_temp = TemperatureCache::raw_temperature(white_hct);
425        let black_temp = TemperatureCache::raw_temperature(black_hct);
426
427        assert_approx_eq!(f64, -1.393, blue_temp, epsilon = 0.001);
428        assert_approx_eq!(f64, 2.351, red_temp, epsilon = 0.001);
429        assert_approx_eq!(f64, -0.267, green_temp, epsilon = 0.001);
430        assert_approx_eq!(f64, -0.5, white_temp, epsilon = 0.001);
431        assert_approx_eq!(f64, -0.5, black_temp, epsilon = 0.001);
432    }
433
434    #[test]
435    fn test_complement() {
436        let blue_complement: Argb = TemperatureCache::new(Hct::new(Argb::from_u32(0xff0000ff)))
437            .complement()
438            .into();
439        let red_complement: Argb = TemperatureCache::new(Hct::new(Argb::from_u32(0xffff0000)))
440            .complement()
441            .into();
442        let green_complement: Argb = TemperatureCache::new(Hct::new(Argb::from_u32(0xff00ff00)))
443            .complement()
444            .into();
445        let white_complement: Argb = TemperatureCache::new(Hct::new(Argb::from_u32(0xffffffff)))
446            .complement()
447            .into();
448        let black_complement: Argb = TemperatureCache::new(Hct::new(Argb::from_u32(0xff000000)))
449            .complement()
450            .into();
451
452        assert_eq!(Argb::from_u32(0xff9d0002), blue_complement);
453        assert_eq!(Argb::from_u32(0xff007bfc), red_complement);
454        assert_eq!(Argb::from_u32(0xffffd2c9), green_complement);
455        assert_eq!(Argb::from_u32(0xffffffff), white_complement);
456        assert_eq!(Argb::from_u32(0xff000000), black_complement);
457    }
458
459    #[test]
460    fn test_blue_analogous() {
461        let analogous =
462            TemperatureCache::new(Hct::new(Argb::from_u32(0xff0000ff))).analogous(None, None);
463
464        assert_eq!(Argb::from_u32(0xff00590c), analogous[0].into());
465        assert_eq!(Argb::from_u32(0xff00564e), analogous[1].into());
466        assert_eq!(Argb::from_u32(0xff0000ff), analogous[2].into());
467        assert_eq!(Argb::from_u32(0xff6700cc), analogous[3].into());
468        assert_eq!(Argb::from_u32(0xff81009f), analogous[4].into());
469    }
470
471    #[test]
472    fn test_red_analogous() {
473        let analogous =
474            TemperatureCache::new(Hct::new(Argb::from_u32(0xffff0000))).analogous(None, None);
475
476        assert_eq!(Argb::from_u32(0xfff60082), analogous[0].into());
477        assert_eq!(Argb::from_u32(0xfffc004c), analogous[1].into());
478        assert_eq!(Argb::from_u32(0xffff0000), analogous[2].into());
479        assert_eq!(Argb::from_u32(0xffd95500), analogous[3].into());
480        assert_eq!(Argb::from_u32(0xffaf7200), analogous[4].into());
481    }
482
483    #[test]
484    fn test_green_analogous() {
485        let green_analogous =
486            TemperatureCache::new(Hct::new(Argb::from_u32(0xff00ff00))).analogous(None, None);
487
488        assert_eq!(Argb::from_u32(0xffcee900), green_analogous[0].into());
489        assert_eq!(Argb::from_u32(0xff92f500), green_analogous[1].into());
490        assert_eq!(Argb::from_u32(0xff00ff00), green_analogous[2].into());
491        assert_eq!(Argb::from_u32(0xff00fd6f), green_analogous[3].into());
492        assert_eq!(Argb::from_u32(0xff00fab3), green_analogous[4].into());
493    }
494
495    #[test]
496    fn test_white_analogous() {
497        let analogous =
498            TemperatureCache::new(Hct::new(Argb::from_u32(0xffffffff))).analogous(None, None);
499
500        assert_eq!(Argb::from_u32(0xffffffff), analogous[0].into());
501        assert_eq!(Argb::from_u32(0xffffffff), analogous[1].into());
502        assert_eq!(Argb::from_u32(0xffffffff), analogous[2].into());
503        assert_eq!(Argb::from_u32(0xffffffff), analogous[3].into());
504        assert_eq!(Argb::from_u32(0xffffffff), analogous[4].into());
505    }
506
507    #[test]
508    fn test_black_analogous() {
509        let analogous =
510            TemperatureCache::new(Hct::new(Argb::from_u32(0xff000000))).analogous(None, None);
511
512        assert_eq!(Argb::from_u32(0xff000000), analogous[0].into());
513        assert_eq!(Argb::from_u32(0xff000000), analogous[1].into());
514        assert_eq!(Argb::from_u32(0xff000000), analogous[2].into());
515        assert_eq!(Argb::from_u32(0xff000000), analogous[3].into());
516        assert_eq!(Argb::from_u32(0xff000000), analogous[4].into());
517    }
518}