You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
333 lines
12 KiB
333 lines
12 KiB
/*
|
|
* 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.
|
|
*
|
|
* <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;
|
|
}
|
|
}
|