1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {hsluvToRgb} from 'hsluv'; 16import {clamp} from '../base/math_utils'; 17 18// This file contains a library for working with colors in various color spaces 19// and formats. 20 21const LIGHTNESS_MIN = 0; 22const LIGHTNESS_MAX = 100; 23 24const SATURATION_MIN = 0; 25const SATURATION_MAX = 100; 26 27// Most color formats can be defined using 3 numbers in a standardized order, so 28// this tuple serves as a compact way to store various color formats. 29// E.g. HSL, RGB 30type ColorTuple = [number, number, number]; 31 32// Definition of an HSL color with named fields. 33interface HSL { 34 readonly h: number; // 0-360 35 readonly s: number; // 0-100 36 readonly l: number; // 0-100 37} 38 39// Defines an interface to an immutable color object, which can be defined in 40// any arbitrary format or color space and provides function to modify the color 41// and conversions to CSS compatible style strings. 42// Because this color object is effectively immutable, a new color object is 43// returned when modifying the color, rather than editing the current object 44// in-place. 45// Also, because these objects are immutable, it's expected that readonly 46// properties such as |cssString| are efficient, as they can be computed at 47// creation time, so they may be used in the hot path (render loop). 48export interface Color { 49 readonly cssString: string; 50 51 // The perceived brightness of the color using a weighted average of the 52 // r, g and b channels based on human perception. 53 readonly perceivedBrightness: number; 54 55 // Bring up the lightness by |percent| percent. 56 lighten(percent: number, max?: number): Color; 57 58 // Bring down the lightness by |percent| percent. 59 darken(percent: number, min?: number): Color; 60 61 // Bring up the saturation by |percent| percent. 62 saturate(percent: number, max?: number): Color; 63 64 // Bring down the saturation by |percent| percent. 65 desaturate(percent: number, min?: number): Color; 66 67 // Set one or more HSL values. 68 setHSL(hsl: Partial<HSL>): Color; 69 70 setAlpha(alpha: number | undefined): Color; 71} 72 73// Common base class for HSL colors. Avoids code duplication. 74abstract class HSLColorBase<T extends Color> { 75 readonly hsl: ColorTuple; 76 readonly alpha?: number; 77 78 // Values are in the range: 79 // Hue: 0-360 80 // Saturation: 0-100 81 // Lightness: 0-100 82 // Alpha: 0-1 83 constructor(init: ColorTuple | HSL | string, alpha?: number) { 84 if (Array.isArray(init)) { 85 this.hsl = init; 86 } else if (typeof init === 'string') { 87 const rgb = hexToRgb(init); 88 this.hsl = rgbToHsl(rgb); 89 } else { 90 this.hsl = [init.h, init.s, init.l]; 91 } 92 this.alpha = alpha; 93 } 94 95 // Subclasses should implement this to teach the base class how to create a 96 // new object of the subclass type. 97 abstract create(hsl: ColorTuple | HSL, alpha?: number): T; 98 99 lighten(amount: number, max = LIGHTNESS_MAX): T { 100 const [h, s, l] = this.hsl; 101 const newLightness = clamp(l + amount, LIGHTNESS_MIN, max); 102 return this.create([h, s, newLightness], this.alpha); 103 } 104 105 darken(amount: number, min = LIGHTNESS_MIN): T { 106 const [h, s, l] = this.hsl; 107 const newLightness = clamp(l - amount, min, LIGHTNESS_MAX); 108 return this.create([h, s, newLightness], this.alpha); 109 } 110 111 saturate(amount: number, max = SATURATION_MAX): T { 112 const [h, s, l] = this.hsl; 113 const newSaturation = clamp(s + amount, SATURATION_MIN, max); 114 return this.create([h, newSaturation, l], this.alpha); 115 } 116 117 desaturate(amount: number, min = SATURATION_MIN): T { 118 const [h, s, l] = this.hsl; 119 const newSaturation = clamp(s - amount, min, SATURATION_MAX); 120 return this.create([h, newSaturation, l], this.alpha); 121 } 122 123 setHSL(hsl: Partial<HSL>): T { 124 const [h, s, l] = this.hsl; 125 return this.create({h, s, l, ...hsl}, this.alpha); 126 } 127 128 setAlpha(alpha: number | undefined): T { 129 return this.create(this.hsl, alpha); 130 } 131} 132 133// Describes a color defined in standard HSL color space. 134export class HSLColor extends HSLColorBase<HSLColor> implements Color { 135 readonly cssString: string; 136 readonly perceivedBrightness: number; 137 138 // Values are in the range: 139 // Hue: 0-360 140 // Saturation: 0-100 141 // Lightness: 0-100 142 // Alpha: 0-1 143 constructor(hsl: ColorTuple | HSL | string, alpha?: number) { 144 super(hsl, alpha); 145 146 const [r, g, b] = hslToRgb(...this.hsl); 147 148 this.perceivedBrightness = perceivedBrightness(r, g, b); 149 150 if (this.alpha === undefined) { 151 this.cssString = `rgb(${r} ${g} ${b})`; 152 } else { 153 this.cssString = `rgb(${r} ${g} ${b} / ${this.alpha})`; 154 } 155 } 156 157 create(values: ColorTuple | HSL, alpha?: number | undefined): HSLColor { 158 return new HSLColor(values, alpha); 159 } 160} 161 162// Describes a color defined in HSLuv color space. 163// See: https://www.hsluv.org/ 164export class HSLuvColor extends HSLColorBase<HSLuvColor> implements Color { 165 readonly cssString: string; 166 readonly perceivedBrightness: number; 167 168 constructor(hsl: ColorTuple | HSL, alpha?: number) { 169 super(hsl, alpha); 170 171 const rgb = hsluvToRgb(this.hsl); 172 const r = Math.floor(rgb[0] * 255); 173 const g = Math.floor(rgb[1] * 255); 174 const b = Math.floor(rgb[2] * 255); 175 176 this.perceivedBrightness = perceivedBrightness(r, g, b); 177 178 if (this.alpha === undefined) { 179 this.cssString = `rgb(${r} ${g} ${b})`; 180 } else { 181 this.cssString = `rgb(${r} ${g} ${b} / ${this.alpha})`; 182 } 183 } 184 185 create(raw: ColorTuple | HSL, alpha?: number | undefined): HSLuvColor { 186 return new HSLuvColor(raw, alpha); 187 } 188} 189 190// Hue: 0-360 191// Saturation: 0-100 192// Lightness: 0-100 193// RGB: 0-255 194export function hslToRgb(h: number, s: number, l: number): ColorTuple { 195 h = h; 196 s = s / SATURATION_MAX; 197 l = l / LIGHTNESS_MAX; 198 199 const c = (1 - Math.abs(2 * l - 1)) * s; 200 const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); 201 const m = l - c / 2; 202 203 let [r, g, b] = [0, 0, 0]; 204 205 if (0 <= h && h < 60) { 206 [r, g, b] = [c, x, 0]; 207 } else if (60 <= h && h < 120) { 208 [r, g, b] = [x, c, 0]; 209 } else if (120 <= h && h < 180) { 210 [r, g, b] = [0, c, x]; 211 } else if (180 <= h && h < 240) { 212 [r, g, b] = [0, x, c]; 213 } else if (240 <= h && h < 300) { 214 [r, g, b] = [x, 0, c]; 215 } else if (300 <= h && h < 360) { 216 [r, g, b] = [c, 0, x]; 217 } 218 219 // Convert to 0-255 range 220 r = Math.round((r + m) * 255); 221 g = Math.round((g + m) * 255); 222 b = Math.round((b + m) * 255); 223 224 return [r, g, b]; 225} 226 227export function hexToRgb(hex: string): ColorTuple { 228 // Convert hex to RGB first 229 let r: number = 0; 230 let g: number = 0; 231 let b: number = 0; 232 233 if (hex.length === 4) { 234 r = parseInt(hex[1] + hex[1], 16); 235 g = parseInt(hex[2] + hex[2], 16); 236 b = parseInt(hex[3] + hex[3], 16); 237 } else if (hex.length === 7) { 238 r = parseInt(hex.substring(1, 3), 16); 239 g = parseInt(hex.substring(3, 5), 16); 240 b = parseInt(hex.substring(5, 7), 16); 241 } 242 243 return [r, g, b]; 244} 245 246export function rgbToHsl(rgb: ColorTuple): ColorTuple { 247 let [r, g, b] = rgb; 248 r /= 255; 249 g /= 255; 250 b /= 255; 251 const max = Math.max(r, g, b); 252 const min = Math.min(r, g, b); 253 let h: number = (max + min) / 2; 254 let s: number = (max + min) / 2; 255 const l: number = (max + min) / 2; 256 257 if (max === min) { 258 h = s = 0; // achromatic 259 } else { 260 const d = max - min; 261 s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 262 switch (max) { 263 case r: 264 h = (g - b) / d + (g < b ? 6 : 0); 265 break; 266 case g: 267 h = (b - r) / d + 2; 268 break; 269 case b: 270 h = (r - g) / d + 4; 271 break; 272 } 273 h /= 6; 274 } 275 276 return [h * 360, s * 100, l * 100]; 277} 278 279// Return the perceived brightness of a color using a weighted average of the 280// r, g and b channels based on human perception. 281function perceivedBrightness(r: number, g: number, b: number): number { 282 // YIQ calculation from https://24ways.org/2010/calculating-color-contrast 283 return (r * 299 + g * 587 + b * 114) / 1000; 284} 285 286// Comparison function used for sorting colors. 287export function colorCompare(a: Color, b: Color): number { 288 return a.cssString.localeCompare(b.cssString); 289} 290