• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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