Skip to content

Commit

Permalink
feat(color): add color channel values and luminosity, saturation, cli…
Browse files Browse the repository at this point in the history
…p 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
  • Loading branch information
straker authored Mar 12, 2024
1 parent bdb7300 commit 9e70199
Show file tree
Hide file tree
Showing 5 changed files with 329 additions and 56 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module.exports = {
root: true,
extends: ['prettier'],
parserOptions: {
ecmaVersion: 2021
ecmaVersion: 2023
},
env: {
node: true,
Expand Down
240 changes: 201 additions & 39 deletions lib/commons/color/color.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Colorjs } from '../../core/imports';

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

/**
* @class Color
Expand All @@ -12,7 +11,26 @@ const hslRegex = /hsl\(\s*([\d.]+)(rad|turn)/;
* @param {number} alpha
*/
export default class Color {
// color channel values typically in the range of 0-1 (can go below or above)
#r;
#g;
#b;
// color component values resolved to the sRGB color space (0-255)
#red;
#green;
#blue;

constructor(red, green, blue, alpha = 1) {
if (red instanceof Color) {
// preserve out of gamut values
const { r, g, b } = red;
this.r = r;
this.g = g;
this.b = b;
this.alpha = red.alpha;
return;
}

/** @type {number} */
this.red = red;

Expand All @@ -26,6 +44,60 @@ export default class Color {
this.alpha = alpha;
}

get r() {
return this.#r;
}

set r(value) {
this.#r = value;
this.#red = Math.round(clamp(value, 0, 1) * 255);
}

get g() {
return this.#g;
}

set g(value) {
this.#g = value;
this.#green = Math.round(clamp(value, 0, 1) * 255);
}

get b() {
return this.#b;
}

set b(value) {
this.#b = value;
this.#blue = Math.round(clamp(value, 0, 1) * 255);
}

get red() {
return this.#red;
}

set red(value) {
this.#r = value / 255;
this.#red = clamp(value, 0, 255);
}

get green() {
return this.#green;
}

set green(value) {
this.#g = value / 255;
this.#green = clamp(value, 0, 255);
}

get blue() {
return this.#blue;
}

set blue(value) {
this.#b = value / 255;
this.#blue = clamp(value, 0, 255);
}

/**
* Provide the hex string value for the color
* @method toHexString
Expand All @@ -34,9 +106,9 @@ export default class Color {
* @return {string}
*/
toHexString() {
var redString = Math.round(this.red).toString(16);
var greenString = Math.round(this.green).toString(16);
var blueString = Math.round(this.blue).toString(16);
const redString = Math.round(this.red).toString(16);
const greenString = Math.round(this.green).toString(16);
const blueString = Math.round(this.blue).toString(16);
return (
'#' +
(this.red > 15.5 ? redString : '0' + redString) +
Expand All @@ -57,28 +129,12 @@ export default class Color {
* @instance
*/
parseString(colorString) {
// Colorjs currently does not support rad or turn angle values
// @see https://github.com/LeaVerou/color.js/issues/311
colorString = colorString.replace(hslRegex, (match, angle, unit) => {
const value = angle + unit;

switch (unit) {
case 'rad':
return match.replace(value, radToDeg(angle));
case 'turn':
return match.replace(value, turnToDeg(angle));
}
});

try {
// srgb values are between 0 and 1
const color = new Colorjs(colorString).to('srgb');
// when converting from one color space to srgb
// the values of rgb may be above 1 so we need to clamp them
// we also need to round the final value as rgb values don't have decimals
this.red = Math.round(clamp(color.r, 0, 1) * 255);
this.green = Math.round(clamp(color.g, 0, 1) * 255);
this.blue = Math.round(clamp(color.b, 0, 1) * 255);
this.r = color.r;
this.g = color.g;
this.b = color.b;
// color.alpha is a Number object so convert it to a number
this.alpha = +color.alpha;
} catch (err) {
Expand Down Expand Up @@ -137,32 +193,138 @@ export default class Color {
* @return {number} The luminance value, ranges from 0 to 1
*/
getRelativeLuminance() {
var rSRGB = this.red / 255;
var gSRGB = this.green / 255;
var bSRGB = this.blue / 255;
const { r: rSRGB, g: gSRGB, b: bSRGB } = this;

var r =
const r =
rSRGB <= 0.03928 ? rSRGB / 12.92 : Math.pow((rSRGB + 0.055) / 1.055, 2.4);
var g =
const g =
gSRGB <= 0.03928 ? gSRGB / 12.92 : Math.pow((gSRGB + 0.055) / 1.055, 2.4);
var b =
const b =
bSRGB <= 0.03928 ? bSRGB / 12.92 : Math.pow((bSRGB + 0.055) / 1.055, 2.4);

return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
* Add a value to the color channels
* @private
* @param {number} value The value to add
* @return {Color} A new color instance
*/
#add(value) {
const C = new Color(this);
C.r += value;
C.g += value;
C.b += value;
return C;
}

/**
* Get the luminosity of a color
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
* @method getLuminosity
* @memberof axe.commons.color.Color
* @instance
* @return {number} The luminosity of the color
*/
getLuminosity() {
return 0.3 * this.r + 0.59 * this.g + 0.11 * this.b;
}

/**
* Set the luminosity of a color
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
* @method setLuminosity
* @memberof axe.commons.color.Color
* @instance
* @param {number} L The luminosity
* @return {Color} A new color instance
*/
setLuminosity(L) {
const d = L - this.getLuminosity();
return this.#add(d).clip();
}

/**
* Get the saturation of a color
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
* @method getSaturation
* @memberof axe.commons.color.Color
* @instance
* @return {number} The saturation of the color
*/
getSaturation() {
return Math.max(this.r, this.g, this.b) - Math.min(this.r, this.g, this.b);
}

/**
* Set the saturation of a color
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
* @method setSaturation
* @memberof axe.commons.color.Color
* @instance
* @param {number} s The saturation
* @return {Color} A new color instance
*/
setSaturation(s) {
const C = new Color(this);
const colorEntires = [
{ name: 'r', value: C.r },
{ name: 'g', value: C.g },
{ name: 'b', value: C.b }
];

// find the min, mid, and max values of the color components
const [Cmin, Cmid, Cmax] = colorEntires.sort((a, b) => {
return a.value - b.value;
});

if (Cmax.value > Cmin.value) {
Cmid.value = ((Cmid.value - Cmin.value) * s) / (Cmax.value - Cmin.value);
Cmax.value = s;
} else {
Cmid.value = Cmax.value = 0;
}

Cmin.value = 0;

C[Cmax.name] = Cmax.value;
C[Cmin.name] = Cmin.value;
C[Cmid.name] = Cmid.value;
return C;
}

/**
* Clip the color between RGB 0-1 accounting for the luminosity of the color. Color must be normalized before calling.
* using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable
* @method clip
* @memberof axe.commons.color.Color
* @instance
* @return {Color} A new color instance clipped between 0-1
*/
clip() {
const C = new Color(this);
const L = C.getLuminosity();
const n = Math.min(C.r, C.g, C.b);
const x = Math.max(C.r, C.g, C.b);

if (n < 0) {
C.r = L + ((C.r - L) * L) / (L - n);
C.g = L + ((C.g - L) * L) / (L - n);
C.b = L + ((C.b - L) * L) / (L - n);
}

if (x > 1) {
C.r = L + ((C.r - L) * (1 - L)) / (x - L);
C.g = L + ((C.g - L) * (1 - L)) / (x - L);
C.b = L + ((C.b - L) * (1 - L)) / (x - L);
}

return C;
}
}

// clamp a value between two numbers (inclusive)
function clamp(value, min, max) {
return Math.min(Math.max(min, value), max);
}

// convert radians to degrees
function radToDeg(rad) {
return (rad * 180) / Math.PI;
}

// convert turn to degrees
function turnToDeg(turn) {
return turn * 360;
}
Loading

0 comments on commit 9e70199

Please sign in to comment.