1 /* 2 * Copyright 2021 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.palettes; 18 19 import com.google.ux.material.libmonet.hct.Hct; 20 import java.util.HashMap; 21 import java.util.Map; 22 23 /** 24 * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. 25 * 26 * <p>TonalPalette is intended for use in a single thread due to its stateful caching. 27 */ 28 public final class TonalPalette { 29 Map<Integer, Integer> cache; 30 Hct keyColor; 31 double hue; 32 double chroma; 33 34 /** 35 * Create tones using the HCT hue and chroma from a color. 36 * 37 * @param argb ARGB representation of a color 38 * @return Tones matching that color's hue and chroma. 39 */ fromInt(int argb)40 public static TonalPalette fromInt(int argb) { 41 return fromHct(Hct.fromInt(argb)); 42 } 43 44 /** 45 * Create tones using a HCT color. 46 * 47 * @param hct HCT representation of a color. 48 * @return Tones matching that color's hue and chroma. 49 */ fromHct(Hct hct)50 public static TonalPalette fromHct(Hct hct) { 51 return new TonalPalette(hct.getHue(), hct.getChroma(), hct); 52 } 53 54 /** 55 * Create tones from a defined HCT hue and chroma. 56 * 57 * @param hue HCT hue 58 * @param chroma HCT chroma 59 * @return Tones matching hue and chroma. 60 */ fromHueAndChroma(double hue, double chroma)61 public static TonalPalette fromHueAndChroma(double hue, double chroma) { 62 final Hct keyColor = new KeyColor(hue, chroma).create(); 63 return new TonalPalette(hue, chroma, keyColor); 64 } 65 TonalPalette(double hue, double chroma, Hct keyColor)66 private TonalPalette(double hue, double chroma, Hct keyColor) { 67 cache = new HashMap<>(); 68 this.hue = hue; 69 this.chroma = chroma; 70 this.keyColor = keyColor; 71 } 72 73 /** 74 * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. 75 * 76 * @param tone HCT tone, measured from 0 to 100. 77 * @return ARGB representation of a color with that tone. 78 */ tone(int tone)79 public int tone(int tone) { 80 Integer color = cache.get(tone); 81 if (color == null) { 82 color = Hct.from(this.hue, this.chroma, tone).toInt(); 83 cache.put(tone, color); 84 } 85 return color; 86 } 87 88 /** Given a tone, use hue and chroma of palette to create a color, and return it as HCT. */ getHct(double tone)89 public Hct getHct(double tone) { 90 return Hct.from(this.hue, this.chroma, tone); 91 } 92 93 /** The chroma of the Tonal Palette, in HCT. Ranges from 0 to ~130 (for sRGB gamut). */ getChroma()94 public double getChroma() { 95 return this.chroma; 96 } 97 98 /** The hue of the Tonal Palette, in HCT. Ranges from 0 to 360. */ getHue()99 public double getHue() { 100 return this.hue; 101 } 102 103 /** The key color is the first tone, starting from T50, that matches the palette's chroma. */ getKeyColor()104 public Hct getKeyColor() { 105 return this.keyColor; 106 } 107 108 /** Key color is a color that represents the hue and chroma of a tonal palette. */ 109 private static final class KeyColor { 110 private final double hue; 111 private final double requestedChroma; 112 113 // Cache that maps tone to max chroma to avoid duplicated HCT calculation. 114 private final Map<Integer, Double> chromaCache = new HashMap<>(); 115 private static final double MAX_CHROMA_VALUE = 200.0; 116 117 /** Key color is a color that represents the hue and chroma of a tonal palette */ KeyColor(double hue, double requestedChroma)118 public KeyColor(double hue, double requestedChroma) { 119 this.hue = hue; 120 this.requestedChroma = requestedChroma; 121 } 122 123 /** 124 * Creates a key color from a [hue] and a [chroma]. The key color is the first tone, starting 125 * from T50, matching the given hue and chroma. 126 * 127 * @return Key color [Hct] 128 */ create()129 public Hct create() { 130 // Pivot around T50 because T50 has the most chroma available, on 131 // average. Thus it is most likely to have a direct answer. 132 final int pivotTone = 50; 133 final int toneStepSize = 1; 134 // Epsilon to accept values slightly higher than the requested chroma. 135 final double epsilon = 0.01; 136 137 // Binary search to find the tone that can provide a chroma that is closest 138 // to the requested chroma. 139 int lowerTone = 0; 140 int upperTone = 100; 141 while (lowerTone < upperTone) { 142 final int midTone = (lowerTone + upperTone) / 2; 143 boolean isAscending = maxChroma(midTone) < maxChroma(midTone + toneStepSize); 144 boolean sufficientChroma = maxChroma(midTone) >= requestedChroma - epsilon; 145 146 if (sufficientChroma) { 147 // Either range [lowerTone, midTone] or [midTone, upperTone] has 148 // the answer, so search in the range that is closer the pivot tone. 149 if (Math.abs(lowerTone - pivotTone) < Math.abs(upperTone - pivotTone)) { 150 upperTone = midTone; 151 } else { 152 if (lowerTone == midTone) { 153 return Hct.from(this.hue, this.requestedChroma, lowerTone); 154 } 155 lowerTone = midTone; 156 } 157 } else { 158 // As there is no sufficient chroma in the midTone, follow the direction to the chroma 159 // peak. 160 if (isAscending) { 161 lowerTone = midTone + toneStepSize; 162 } else { 163 // Keep midTone for potential chroma peak. 164 upperTone = midTone; 165 } 166 } 167 } 168 169 return Hct.from(this.hue, this.requestedChroma, lowerTone); 170 } 171 172 // Find the maximum chroma for a given tone maxChroma(int tone)173 private double maxChroma(int tone) { 174 if (chromaCache.get(tone) == null) { 175 Double newChroma = Hct.from(hue, MAX_CHROMA_VALUE, tone).getChroma(); 176 if (newChroma != null) { 177 chromaCache.put(tone, newChroma); 178 } 179 } 180 return chromaCache.get(tone); 181 } 182 } 183 } 184