• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2022 Google LLC
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.google.ux.material.libmonet.temperature;
18 
19 import com.google.ux.material.libmonet.hct.Hct;
20 import com.google.ux.material.libmonet.utils.ColorUtils;
21 import com.google.ux.material.libmonet.utils.MathUtils;
22 import java.util.ArrayList;
23 import java.util.Collections;
24 import java.util.Comparator;
25 import java.util.HashMap;
26 import java.util.List;
27 import java.util.Map;
28 
29 /**
30  * Design utilities using color temperature theory.
31  *
32  * <p>Analogous colors, complementary color, and cache to efficiently, lazily, generate data for
33  * calculations when needed.
34  */
35 public final class TemperatureCache {
36   private final Hct input;
37 
38   private Hct precomputedComplement;
39   private List<Hct> precomputedHctsByTemp;
40   private List<Hct> precomputedHctsByHue;
41   private Map<Hct, Double> precomputedTempsByHct;
42 
TemperatureCache()43   private TemperatureCache() {
44     throw new UnsupportedOperationException();
45   }
46 
47   /**
48    * Create a cache that allows calculation of ex. complementary and analogous colors.
49    *
50    * @param input Color to find complement/analogous colors of. Any colors will have the same tone,
51    * and chroma as the input color, modulo any restrictions due to the other hues having lower
52    * limits on chroma.
53    */
TemperatureCache(Hct input)54   public TemperatureCache(Hct input) {
55     this.input = input;
56   }
57 
58   /**
59    * A color that complements the input color aesthetically.
60    *
61    * <p>In art, this is usually described as being across the color wheel. History of this shows
62    * intent as a color that is just as cool-warm as the input color is warm-cool.
63    */
getComplement()64   public Hct getComplement() {
65     if (precomputedComplement != null) {
66       return precomputedComplement;
67     }
68 
69     double coldestHue = getColdest().getHue();
70     double coldestTemp = getTempsByHct().get(getColdest());
71 
72     double warmestHue = getWarmest().getHue();
73     double warmestTemp = getTempsByHct().get(getWarmest());
74     double range = warmestTemp - coldestTemp;
75     boolean startHueIsColdestToWarmest = isBetween(input.getHue(), coldestHue, warmestHue);
76     double startHue = startHueIsColdestToWarmest ? warmestHue : coldestHue;
77     double endHue = startHueIsColdestToWarmest ? coldestHue : warmestHue;
78     double directionOfRotation = 1.;
79     double smallestError = 1000.;
80     Hct answer = getHctsByHue().get((int) Math.round(input.getHue()));
81 
82     double complementRelativeTemp = (1. - getRelativeTemperature(input));
83     // Find the color in the other section, closest to the inverse percentile
84     // of the input color. This is the complement.
85     for (double hueAddend = 0.; hueAddend <= 360.; hueAddend += 1.) {
86       double hue = MathUtils.sanitizeDegreesDouble(
87           startHue + directionOfRotation * hueAddend);
88       if (!isBetween(hue, startHue, endHue)) {
89         continue;
90       }
91       Hct possibleAnswer = getHctsByHue().get((int) Math.round(hue));
92       double relativeTemp =
93           (getTempsByHct().get(possibleAnswer) - coldestTemp) / range;
94       double error = Math.abs(complementRelativeTemp - relativeTemp);
95       if (error < smallestError) {
96         smallestError = error;
97         answer = possibleAnswer;
98       }
99     }
100     precomputedComplement = answer;
101     return precomputedComplement;
102   }
103 
104   /**
105    * 5 colors that pair well with the input color.
106    *
107    * <p>The colors are equidistant in temperature and adjacent in hue.
108    */
getAnalogousColors()109   public List<Hct> getAnalogousColors() {
110     return getAnalogousColors(5, 12);
111   }
112 
113   /**
114    * A set of colors with differing hues, equidistant in temperature.
115    *
116    * <p>In art, this is usually described as a set of 5 colors on a color wheel divided into 12
117    * sections. This method allows provision of either of those values.
118    *
119    * <p>Behavior is undefined when count or divisions is 0. When divisions < count, colors repeat.
120    *
121    * @param count The number of colors to return, includes the input color.
122    * @param divisions The number of divisions on the color wheel.
123    */
getAnalogousColors(int count, int divisions)124   public List<Hct> getAnalogousColors(int count, int divisions) {
125     // The starting hue is the hue of the input color.
126     int startHue = (int) Math.round(input.getHue());
127     Hct startHct = getHctsByHue().get(startHue);
128     double lastTemp = getRelativeTemperature(startHct);
129 
130     List<Hct> allColors = new ArrayList<>();
131     allColors.add(startHct);
132 
133     double absoluteTotalTempDelta = 0.f;
134     for (int i = 0; i < 360; i++) {
135       int hue = MathUtils.sanitizeDegreesInt(startHue + i);
136       Hct hct = getHctsByHue().get(hue);
137       double temp = getRelativeTemperature(hct);
138       double tempDelta = Math.abs(temp - lastTemp);
139       lastTemp = temp;
140       absoluteTotalTempDelta += tempDelta;
141     }
142 
143     int hueAddend = 1;
144     double tempStep = absoluteTotalTempDelta / (double) divisions;
145     double totalTempDelta = 0.0;
146     lastTemp = getRelativeTemperature(startHct);
147     while (allColors.size() < divisions) {
148       int hue = MathUtils.sanitizeDegreesInt(startHue + hueAddend);
149       Hct hct = getHctsByHue().get(hue);
150       double temp = getRelativeTemperature(hct);
151       double tempDelta = Math.abs(temp - lastTemp);
152       totalTempDelta += tempDelta;
153 
154       double desiredTotalTempDeltaForIndex = (allColors.size() * tempStep);
155       boolean indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex;
156       int indexAddend = 1;
157       // Keep adding this hue to the answers until its temperature is
158       // insufficient. This ensures consistent behavior when there aren't
159       // `divisions` discrete steps between 0 and 360 in hue with `tempStep`
160       // delta in temperature between them.
161       //
162       // For example, white and black have no analogues: there are no other
163       // colors at T100/T0. Therefore, they should just be added to the array
164       // as answers.
165       while (indexSatisfied && allColors.size() < divisions) {
166         allColors.add(hct);
167         desiredTotalTempDeltaForIndex = ((allColors.size() + indexAddend) * tempStep);
168         indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex;
169         indexAddend++;
170       }
171       lastTemp = temp;
172       hueAddend++;
173 
174       if (hueAddend > 360) {
175         while (allColors.size() < divisions) {
176           allColors.add(hct);
177         }
178         break;
179       }
180     }
181 
182     List<Hct> answers = new ArrayList<>();
183     answers.add(input);
184 
185     int ccwCount = (int) Math.floor(((double) count - 1.0) / 2.0);
186     for (int i = 1; i < (ccwCount + 1); i++) {
187       int index = 0 - i;
188       while (index < 0) {
189         index = allColors.size() + index;
190       }
191       if (index >= allColors.size()) {
192         index = index % allColors.size();
193       }
194       answers.add(0, allColors.get(index));
195     }
196 
197     int cwCount = count - ccwCount - 1;
198     for (int i = 1; i < (cwCount + 1); i++) {
199       int index = i;
200       while (index < 0) {
201         index = allColors.size() + index;
202       }
203       if (index >= allColors.size()) {
204         index = index % allColors.size();
205       }
206       answers.add(allColors.get(index));
207     }
208 
209     return answers;
210   }
211 
212   /**
213    * Temperature relative to all colors with the same chroma and tone.
214    *
215    * @param hct HCT to find the relative temperature of.
216    * @return Value on a scale from 0 to 1.
217    */
getRelativeTemperature(Hct hct)218   public double getRelativeTemperature(Hct hct) {
219     double range = getTempsByHct().get(getWarmest()) - getTempsByHct().get(getColdest());
220     double differenceFromColdest =
221         getTempsByHct().get(hct) - getTempsByHct().get(getColdest());
222     // Handle when there's no difference in temperature between warmest and
223     // coldest: for example, at T100, only one color is available, white.
224     if (range == 0.) {
225       return 0.5;
226     }
227     return differenceFromColdest / range;
228   }
229 
230   /**
231    * Value representing cool-warm factor of a color. Values below 0 are considered cool, above,
232    * warm.
233    *
234    * <p>Color science has researched emotion and harmony, which art uses to select colors. Warm-cool
235    * is the foundation of analogous and complementary colors. See: - Li-Chen Ou's Chapter 19 in
236    * Handbook of Color Psychology (2015). - Josef Albers' Interaction of Color chapters 19 and 21.
237    *
238    * <p>Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space.
239    * Return value has these properties:<br>
240    * - Values below 0 are cool, above 0 are warm.<br>
241    * - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130.<br>
242    * - Upper bound: 8.61. Chroma is infinite. Assuming max of Lab chroma 130.
243    */
rawTemperature(Hct color)244   public static double rawTemperature(Hct color) {
245     double[] lab = ColorUtils.labFromArgb(color.toInt());
246     double hue = MathUtils.sanitizeDegreesDouble(Math.toDegrees(Math.atan2(lab[2], lab[1])));
247     double chroma = Math.hypot(lab[1], lab[2]);
248     return -0.5
249         + 0.02
250             * Math.pow(chroma, 1.07)
251             * Math.cos(Math.toRadians(MathUtils.sanitizeDegreesDouble(hue - 50.)));
252   }
253 
254   /** Coldest color with same chroma and tone as input. */
getColdest()255   private Hct getColdest() {
256     return getHctsByTemp().get(0);
257   }
258 
259   /**
260    * HCTs for all colors with the same chroma/tone as the input.
261    *
262    * <p>Sorted by hue, ex. index 0 is hue 0.
263    */
getHctsByHue()264   private List<Hct> getHctsByHue() {
265     if (precomputedHctsByHue != null) {
266       return precomputedHctsByHue;
267     }
268     List<Hct> hcts = new ArrayList<>();
269     for (double hue = 0.; hue <= 360.; hue += 1.) {
270       Hct colorAtHue = Hct.from(hue, input.getChroma(), input.getTone());
271       hcts.add(colorAtHue);
272     }
273     precomputedHctsByHue = Collections.unmodifiableList(hcts);
274     return precomputedHctsByHue;
275   }
276 
277   /**
278    * HCTs for all colors with the same chroma/tone as the input.
279    *
280    * <p>Sorted from coldest first to warmest last.
281    */
282   // Prevent lint for Comparator not being available on Android before API level 24, 7.0, 2016.
283   // "AndroidJdkLibsChecker" for one linter, "NewApi" for another.
284   // A java_library Bazel rule with an Android constraint cannot skip these warnings without this
285   // annotation; another solution would be to create an android_library rule and supply
286   // AndroidManifest with an SDK set higher than 23.
287   @SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
getHctsByTemp()288   private List<Hct> getHctsByTemp() {
289     if (precomputedHctsByTemp != null) {
290       return precomputedHctsByTemp;
291     }
292 
293     List<Hct> hcts = new ArrayList<>(getHctsByHue());
294     hcts.add(input);
295     Comparator<Hct> temperaturesComparator =
296         Comparator.comparing((Hct arg) -> getTempsByHct().get(arg), Double::compareTo);
297     Collections.sort(hcts, temperaturesComparator);
298     precomputedHctsByTemp = hcts;
299     return precomputedHctsByTemp;
300   }
301 
302   /** Keys of HCTs in getHctsByTemp, values of raw temperature. */
getTempsByHct()303   private Map<Hct, Double> getTempsByHct() {
304     if (precomputedTempsByHct != null) {
305       return precomputedTempsByHct;
306     }
307 
308     List<Hct> allHcts = new ArrayList<>(getHctsByHue());
309     allHcts.add(input);
310 
311     Map<Hct, Double> temperaturesByHct = new HashMap<>();
312     for (Hct hct : allHcts) {
313       temperaturesByHct.put(hct, rawTemperature(hct));
314     }
315 
316     precomputedTempsByHct = temperaturesByHct;
317     return precomputedTempsByHct;
318   }
319 
320   /** Warmest color with same chroma and tone as input. */
getWarmest()321   private Hct getWarmest() {
322     return getHctsByTemp().get(getHctsByTemp().size() - 1);
323   }
324 
325   /** Determines if an angle is between two other angles, rotating clockwise. */
isBetween(double angle, double a, double b)326   private static boolean isBetween(double angle, double a, double b) {
327     if (a < b) {
328       return a <= angle && angle <= b;
329     }
330     return a <= angle || angle <= b;
331   }
332 }
333