/* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.ux.material.libmonet.temperature; import com.google.ux.material.libmonet.hct.Hct; import com.google.ux.material.libmonet.utils.ColorUtils; import com.google.ux.material.libmonet.utils.MathUtils; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Design utilities using color temperature theory. * *

Analogous colors, complementary color, and cache to efficiently, lazily, generate data for * calculations when needed. */ public final class TemperatureCache { private final Hct input; private Hct precomputedComplement; private List precomputedHctsByTemp; private List precomputedHctsByHue; private Map precomputedTempsByHct; private TemperatureCache() { throw new UnsupportedOperationException(); } /** * Create a cache that allows calculation of ex. complementary and analogous colors. * * @param input Color to find complement/analogous colors of. Any colors will have the same tone, * and chroma as the input color, modulo any restrictions due to the other hues having lower * limits on chroma. */ public TemperatureCache(Hct input) { this.input = input; } /** * A color that complements the input color aesthetically. * *

In art, this is usually described as being across the color wheel. History of this shows * intent as a color that is just as cool-warm as the input color is warm-cool. */ public Hct getComplement() { if (precomputedComplement != null) { return precomputedComplement; } double coldestHue = getColdest().getHue(); double coldestTemp = getTempsByHct().get(getColdest()); double warmestHue = getWarmest().getHue(); double warmestTemp = getTempsByHct().get(getWarmest()); double range = warmestTemp - coldestTemp; boolean startHueIsColdestToWarmest = isBetween(input.getHue(), coldestHue, warmestHue); double startHue = startHueIsColdestToWarmest ? warmestHue : coldestHue; double endHue = startHueIsColdestToWarmest ? coldestHue : warmestHue; double directionOfRotation = 1.; double smallestError = 1000.; Hct answer = getHctsByHue().get((int) Math.round(input.getHue())); double complementRelativeTemp = (1. - getRelativeTemperature(input)); // Find the color in the other section, closest to the inverse percentile // of the input color. This is the complement. for (double hueAddend = 0.; hueAddend <= 360.; hueAddend += 1.) { double hue = MathUtils.sanitizeDegreesDouble( startHue + directionOfRotation * hueAddend); if (!isBetween(hue, startHue, endHue)) { continue; } Hct possibleAnswer = getHctsByHue().get((int) Math.round(hue)); double relativeTemp = (getTempsByHct().get(possibleAnswer) - coldestTemp) / range; double error = Math.abs(complementRelativeTemp - relativeTemp); if (error < smallestError) { smallestError = error; answer = possibleAnswer; } } precomputedComplement = answer; return precomputedComplement; } /** * 5 colors that pair well with the input color. * *

The colors are equidistant in temperature and adjacent in hue. */ public List getAnalogousColors() { return getAnalogousColors(5, 12); } /** * A set of colors with differing hues, equidistant in temperature. * *

In art, this is usually described as a set of 5 colors on a color wheel divided into 12 * sections. This method allows provision of either of those values. * *

Behavior is undefined when count or divisions is 0. When divisions < count, colors repeat. * * @param count The number of colors to return, includes the input color. * @param divisions The number of divisions on the color wheel. */ public List getAnalogousColors(int count, int divisions) { // The starting hue is the hue of the input color. int startHue = (int) Math.round(input.getHue()); Hct startHct = getHctsByHue().get(startHue); double lastTemp = getRelativeTemperature(startHct); List allColors = new ArrayList<>(); allColors.add(startHct); double absoluteTotalTempDelta = 0.f; for (int i = 0; i < 360; i++) { int hue = MathUtils.sanitizeDegreesInt(startHue + i); Hct hct = getHctsByHue().get(hue); double temp = getRelativeTemperature(hct); double tempDelta = Math.abs(temp - lastTemp); lastTemp = temp; absoluteTotalTempDelta += tempDelta; } int hueAddend = 1; double tempStep = absoluteTotalTempDelta / (double) divisions; double totalTempDelta = 0.0; lastTemp = getRelativeTemperature(startHct); while (allColors.size() < divisions) { int hue = MathUtils.sanitizeDegreesInt(startHue + hueAddend); Hct hct = getHctsByHue().get(hue); double temp = getRelativeTemperature(hct); double tempDelta = Math.abs(temp - lastTemp); totalTempDelta += tempDelta; double desiredTotalTempDeltaForIndex = (allColors.size() * tempStep); boolean indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex; int indexAddend = 1; // Keep adding this hue to the answers until its temperature is // insufficient. This ensures consistent behavior when there aren't // `divisions` discrete steps between 0 and 360 in hue with `tempStep` // delta in temperature between them. // // For example, white and black have no analogues: there are no other // colors at T100/T0. Therefore, they should just be added to the array // as answers. while (indexSatisfied && allColors.size() < divisions) { allColors.add(hct); desiredTotalTempDeltaForIndex = ((allColors.size() + indexAddend) * tempStep); indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex; indexAddend++; } lastTemp = temp; hueAddend++; if (hueAddend > 360) { while (allColors.size() < divisions) { allColors.add(hct); } break; } } List answers = new ArrayList<>(); answers.add(input); int ccwCount = (int) Math.floor(((double) count - 1.0) / 2.0); for (int i = 1; i < (ccwCount + 1); i++) { int index = 0 - i; while (index < 0) { index = allColors.size() + index; } if (index >= allColors.size()) { index = index % allColors.size(); } answers.add(0, allColors.get(index)); } int cwCount = count - ccwCount - 1; for (int i = 1; i < (cwCount + 1); i++) { int index = i; while (index < 0) { index = allColors.size() + index; } if (index >= allColors.size()) { index = index % allColors.size(); } answers.add(allColors.get(index)); } return answers; } /** * Temperature relative to all colors with the same chroma and tone. * * @param hct HCT to find the relative temperature of. * @return Value on a scale from 0 to 1. */ public double getRelativeTemperature(Hct hct) { double range = getTempsByHct().get(getWarmest()) - getTempsByHct().get(getColdest()); double differenceFromColdest = getTempsByHct().get(hct) - getTempsByHct().get(getColdest()); // Handle when there's no difference in temperature between warmest and // coldest: for example, at T100, only one color is available, white. if (range == 0.) { return 0.5; } return differenceFromColdest / range; } /** * Value representing cool-warm factor of a color. Values below 0 are considered cool, above, * warm. * *

Color science has researched emotion and harmony, which art uses to select colors. Warm-cool * is the foundation of analogous and complementary colors. See: - Li-Chen Ou's Chapter 19 in * Handbook of Color Psychology (2015). - Josef Albers' Interaction of Color chapters 19 and 21. * *

Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space. * Return value has these properties:
* - Values below 0 are cool, above 0 are warm.
* - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130.
* - Upper bound: 8.61. Chroma is infinite. Assuming max of Lab chroma 130. */ public static double rawTemperature(Hct color) { double[] lab = ColorUtils.labFromArgb(color.toInt()); double hue = MathUtils.sanitizeDegreesDouble(Math.toDegrees(Math.atan2(lab[2], lab[1]))); double chroma = Math.hypot(lab[1], lab[2]); return -0.5 + 0.02 * Math.pow(chroma, 1.07) * Math.cos(Math.toRadians(MathUtils.sanitizeDegreesDouble(hue - 50.))); } /** Coldest color with same chroma and tone as input. */ private Hct getColdest() { return getHctsByTemp().get(0); } /** * HCTs for all colors with the same chroma/tone as the input. * *

Sorted by hue, ex. index 0 is hue 0. */ private List getHctsByHue() { if (precomputedHctsByHue != null) { return precomputedHctsByHue; } List hcts = new ArrayList<>(); for (double hue = 0.; hue <= 360.; hue += 1.) { Hct colorAtHue = Hct.from(hue, input.getChroma(), input.getTone()); hcts.add(colorAtHue); } precomputedHctsByHue = Collections.unmodifiableList(hcts); return precomputedHctsByHue; } /** * HCTs for all colors with the same chroma/tone as the input. * *

Sorted from coldest first to warmest last. */ // Prevent lint for Comparator not being available on Android before API level 24, 7.0, 2016. // "AndroidJdkLibsChecker" for one linter, "NewApi" for another. // A java_library Bazel rule with an Android constraint cannot skip these warnings without this // annotation; another solution would be to create an android_library rule and supply // AndroidManifest with an SDK set higher than 23. @SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"}) private List getHctsByTemp() { if (precomputedHctsByTemp != null) { return precomputedHctsByTemp; } List hcts = new ArrayList<>(getHctsByHue()); hcts.add(input); Comparator temperaturesComparator = Comparator.comparing((Hct arg) -> getTempsByHct().get(arg), Double::compareTo); Collections.sort(hcts, temperaturesComparator); precomputedHctsByTemp = hcts; return precomputedHctsByTemp; } /** Keys of HCTs in getHctsByTemp, values of raw temperature. */ private Map getTempsByHct() { if (precomputedTempsByHct != null) { return precomputedTempsByHct; } List allHcts = new ArrayList<>(getHctsByHue()); allHcts.add(input); Map temperaturesByHct = new HashMap<>(); for (Hct hct : allHcts) { temperaturesByHct.put(hct, rawTemperature(hct)); } precomputedTempsByHct = temperaturesByHct; return precomputedTempsByHct; } /** Warmest color with same chroma and tone as input. */ private Hct getWarmest() { return getHctsByTemp().get(getHctsByTemp().size() - 1); } /** Determines if an angle is between two other angles, rotating clockwise. */ private static boolean isBetween(double angle, double a, double b) { if (a < b) { return a <= angle && angle <= b; } return a <= angle || angle <= b; } }