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