blob: 2f2022b289c80c259a9870167851b33d093f47b6 [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* 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.android.systemui.monet.temperature;
import com.android.systemui.monet.hct.Hct;
import com.android.systemui.monet.utils.ColorUtils;
import com.android.systemui.monet.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.
*
* <p>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<Hct> precomputedHctsByTemp;
private List<Hct> precomputedHctsByHue;
private Map<Hct, Double> 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.
*
* <p>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.
*
* <p>The colors are equidistant in temperature and adjacent in hue.
*/
public List<Hct> getAnalogousColors() {
return getAnalogousColors(5, 12);
}
/**
* A set of colors with differing hues, equidistant in temperature.
*
* <p>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.
*
* <p>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<Hct> 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<Hct> 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<Hct> 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.
*
* <p>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.
*
* <p>Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space.
* Return value has these properties:<br>
* - Values below 0 are cool, above 0 are warm.<br>
* - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130.<br>
* - 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.
*
* <p>Sorted by hue, ex. index 0 is hue 0.
*/
private List<Hct> getHctsByHue() {
if (precomputedHctsByHue != null) {
return precomputedHctsByHue;
}
List<Hct> 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.
*
* <p>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<Hct> getHctsByTemp() {
if (precomputedHctsByTemp != null) {
return precomputedHctsByTemp;
}
List<Hct> hcts = new ArrayList<>(getHctsByHue());
hcts.add(input);
Comparator<Hct> 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<Hct, Double> getTempsByHct() {
if (precomputedTempsByHct != null) {
return precomputedTempsByHct;
}
List<Hct> allHcts = new ArrayList<>(getHctsByHue());
allHcts.add(input);
Map<Hct, Double> 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;
}
}