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