Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(color): add color channel values and luminosity, saturation, clip functions #4366

Merged
merged 6 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allows us to use private properties and functions in classes (supported by babel).

},
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) {
Copy link
Contributor Author

@straker straker Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we can create a new color from another one. Makes the new functions that create new colors much easier.

const color = new Color(0,0,0);
const color2 = new Color(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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Colorjs 0.5.0 now fully supports grad, rad, and turn so we don't need to do this anymore.

// @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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing outside of the color class should add a value to the color components.

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
Loading