Skip to content

Commit 9e70199

Browse files
authored
feat(color): add color channel values and luminosity, saturation, clip functions (#4366)
Decided to move the Color improvements from https://github.com/dequelabs/axe-core/pull/4365/files into their own PR. This also fixes the `clip` bug mentioned in https://github.com/dequelabs/axe-core/pull/4365/files#r1517706612 No QA needed
1 parent bdb7300 commit 9e70199

File tree

5 files changed

+329
-56
lines changed

5 files changed

+329
-56
lines changed

.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module.exports = {
22
root: true,
33
extends: ['prettier'],
44
parserOptions: {
5-
ecmaVersion: 2021
5+
ecmaVersion: 2023
66
},
77
env: {
88
node: true,

lib/commons/color/color.js

Lines changed: 201 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Colorjs } from '../../core/imports';
22

33
const hexRegex = /^#[0-9a-f]{3,8}$/i;
4-
const hslRegex = /hsl\(\s*([\d.]+)(rad|turn)/;
54

65
/**
76
* @class Color
@@ -12,7 +11,26 @@ const hslRegex = /hsl\(\s*([\d.]+)(rad|turn)/;
1211
* @param {number} alpha
1312
*/
1413
export default class Color {
14+
// color channel values typically in the range of 0-1 (can go below or above)
15+
#r;
16+
#g;
17+
#b;
18+
// color component values resolved to the sRGB color space (0-255)
19+
#red;
20+
#green;
21+
#blue;
22+
1523
constructor(red, green, blue, alpha = 1) {
24+
if (red instanceof Color) {
25+
// preserve out of gamut values
26+
const { r, g, b } = red;
27+
this.r = r;
28+
this.g = g;
29+
this.b = b;
30+
this.alpha = red.alpha;
31+
return;
32+
}
33+
1634
/** @type {number} */
1735
this.red = red;
1836

@@ -26,6 +44,60 @@ export default class Color {
2644
this.alpha = alpha;
2745
}
2846

47+
get r() {
48+
return this.#r;
49+
}
50+
51+
set r(value) {
52+
this.#r = value;
53+
this.#red = Math.round(clamp(value, 0, 1) * 255);
54+
}
55+
56+
get g() {
57+
return this.#g;
58+
}
59+
60+
set g(value) {
61+
this.#g = value;
62+
this.#green = Math.round(clamp(value, 0, 1) * 255);
63+
}
64+
65+
get b() {
66+
return this.#b;
67+
}
68+
69+
set b(value) {
70+
this.#b = value;
71+
this.#blue = Math.round(clamp(value, 0, 1) * 255);
72+
}
73+
74+
get red() {
75+
return this.#red;
76+
}
77+
78+
set red(value) {
79+
this.#r = value / 255;
80+
this.#red = clamp(value, 0, 255);
81+
}
82+
83+
get green() {
84+
return this.#green;
85+
}
86+
87+
set green(value) {
88+
this.#g = value / 255;
89+
this.#green = clamp(value, 0, 255);
90+
}
91+
92+
get blue() {
93+
return this.#blue;
94+
}
95+
96+
set blue(value) {
97+
this.#b = value / 255;
98+
this.#blue = clamp(value, 0, 255);
99+
}
100+
29101
/**
30102
* Provide the hex string value for the color
31103
* @method toHexString
@@ -34,9 +106,9 @@ export default class Color {
34106
* @return {string}
35107
*/
36108
toHexString() {
37-
var redString = Math.round(this.red).toString(16);
38-
var greenString = Math.round(this.green).toString(16);
39-
var blueString = Math.round(this.blue).toString(16);
109+
const redString = Math.round(this.red).toString(16);
110+
const greenString = Math.round(this.green).toString(16);
111+
const blueString = Math.round(this.blue).toString(16);
40112
return (
41113
'#' +
42114
(this.red > 15.5 ? redString : '0' + redString) +
@@ -57,28 +129,12 @@ export default class Color {
57129
* @instance
58130
*/
59131
parseString(colorString) {
60-
// Colorjs currently does not support rad or turn angle values
61-
// @see https://github.com/LeaVerou/color.js/issues/311
62-
colorString = colorString.replace(hslRegex, (match, angle, unit) => {
63-
const value = angle + unit;
64-
65-
switch (unit) {
66-
case 'rad':
67-
return match.replace(value, radToDeg(angle));
68-
case 'turn':
69-
return match.replace(value, turnToDeg(angle));
70-
}
71-
});
72-
73132
try {
74133
// srgb values are between 0 and 1
75134
const color = new Colorjs(colorString).to('srgb');
76-
// when converting from one color space to srgb
77-
// the values of rgb may be above 1 so we need to clamp them
78-
// we also need to round the final value as rgb values don't have decimals
79-
this.red = Math.round(clamp(color.r, 0, 1) * 255);
80-
this.green = Math.round(clamp(color.g, 0, 1) * 255);
81-
this.blue = Math.round(clamp(color.b, 0, 1) * 255);
135+
this.r = color.r;
136+
this.g = color.g;
137+
this.b = color.b;
82138
// color.alpha is a Number object so convert it to a number
83139
this.alpha = +color.alpha;
84140
} catch (err) {
@@ -137,32 +193,138 @@ export default class Color {
137193
* @return {number} The luminance value, ranges from 0 to 1
138194
*/
139195
getRelativeLuminance() {
140-
var rSRGB = this.red / 255;
141-
var gSRGB = this.green / 255;
142-
var bSRGB = this.blue / 255;
196+
const { r: rSRGB, g: gSRGB, b: bSRGB } = this;
143197

144-
var r =
198+
const r =
145199
rSRGB <= 0.03928 ? rSRGB / 12.92 : Math.pow((rSRGB + 0.055) / 1.055, 2.4);
146-
var g =
200+
const g =
147201
gSRGB <= 0.03928 ? gSRGB / 12.92 : Math.pow((gSRGB + 0.055) / 1.055, 2.4);
148-
var b =
202+
const b =
149203
bSRGB <= 0.03928 ? bSRGB / 12.92 : Math.pow((bSRGB + 0.055) / 1.055, 2.4);
150204

151205
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
152206
}
207+
208+
/**
209+
* Add a value to the color channels
210+
* @private
211+
* @param {number} value The value to add
212+
* @return {Color} A new color instance
213+
*/
214+
#add(value) {
215+
const C = new Color(this);
216+
C.r += value;
217+
C.g += value;
218+
C.b += value;
219+
return C;
220+
}
221+
222+
/**
223+
* Get the luminosity of a color
224+
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
225+
* @method getLuminosity
226+
* @memberof axe.commons.color.Color
227+
* @instance
228+
* @return {number} The luminosity of the color
229+
*/
230+
getLuminosity() {
231+
return 0.3 * this.r + 0.59 * this.g + 0.11 * this.b;
232+
}
233+
234+
/**
235+
* Set the luminosity of a color
236+
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
237+
* @method setLuminosity
238+
* @memberof axe.commons.color.Color
239+
* @instance
240+
* @param {number} L The luminosity
241+
* @return {Color} A new color instance
242+
*/
243+
setLuminosity(L) {
244+
const d = L - this.getLuminosity();
245+
return this.#add(d).clip();
246+
}
247+
248+
/**
249+
* Get the saturation of a color
250+
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
251+
* @method getSaturation
252+
* @memberof axe.commons.color.Color
253+
* @instance
254+
* @return {number} The saturation of the color
255+
*/
256+
getSaturation() {
257+
return Math.max(this.r, this.g, this.b) - Math.min(this.r, this.g, this.b);
258+
}
259+
260+
/**
261+
* Set the saturation of a color
262+
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
263+
* @method setSaturation
264+
* @memberof axe.commons.color.Color
265+
* @instance
266+
* @param {number} s The saturation
267+
* @return {Color} A new color instance
268+
*/
269+
setSaturation(s) {
270+
const C = new Color(this);
271+
const colorEntires = [
272+
{ name: 'r', value: C.r },
273+
{ name: 'g', value: C.g },
274+
{ name: 'b', value: C.b }
275+
];
276+
277+
// find the min, mid, and max values of the color components
278+
const [Cmin, Cmid, Cmax] = colorEntires.sort((a, b) => {
279+
return a.value - b.value;
280+
});
281+
282+
if (Cmax.value > Cmin.value) {
283+
Cmid.value = ((Cmid.value - Cmin.value) * s) / (Cmax.value - Cmin.value);
284+
Cmax.value = s;
285+
} else {
286+
Cmid.value = Cmax.value = 0;
287+
}
288+
289+
Cmin.value = 0;
290+
291+
C[Cmax.name] = Cmax.value;
292+
C[Cmin.name] = Cmin.value;
293+
C[Cmid.name] = Cmid.value;
294+
return C;
295+
}
296+
297+
/**
298+
* Clip the color between RGB 0-1 accounting for the luminosity of the color. Color must be normalized before calling.
299+
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
300+
* @method clip
301+
* @memberof axe.commons.color.Color
302+
* @instance
303+
* @return {Color} A new color instance clipped between 0-1
304+
*/
305+
clip() {
306+
const C = new Color(this);
307+
const L = C.getLuminosity();
308+
const n = Math.min(C.r, C.g, C.b);
309+
const x = Math.max(C.r, C.g, C.b);
310+
311+
if (n < 0) {
312+
C.r = L + ((C.r - L) * L) / (L - n);
313+
C.g = L + ((C.g - L) * L) / (L - n);
314+
C.b = L + ((C.b - L) * L) / (L - n);
315+
}
316+
317+
if (x > 1) {
318+
C.r = L + ((C.r - L) * (1 - L)) / (x - L);
319+
C.g = L + ((C.g - L) * (1 - L)) / (x - L);
320+
C.b = L + ((C.b - L) * (1 - L)) / (x - L);
321+
}
322+
323+
return C;
324+
}
153325
}
154326

155327
// clamp a value between two numbers (inclusive)
156328
function clamp(value, min, max) {
157329
return Math.min(Math.max(min, value), max);
158330
}
159-
160-
// convert radians to degrees
161-
function radToDeg(rad) {
162-
return (rad * 180) / Math.PI;
163-
}
164-
165-
// convert turn to degrees
166-
function turnToDeg(turn) {
167-
return turn * 360;
168-
}

0 commit comments

Comments
 (0)