xref: /aosp_15_r20/external/perfetto/ui/src/public/color.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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