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 2 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
177 changes: 168 additions & 9 deletions lib/commons/color/color.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const hslRegex = /hsl\(\s*([\d.]+)(rad|turn)/;
*/
export default class Color {
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);

({ red, green, blue, alpha } = red);
}

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

Expand All @@ -34,9 +38,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 Down Expand Up @@ -137,19 +141,174 @@ 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 { red: rSRGB, green: gSRGB, blue: bSRGB } = this.normalize();

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;
}

/**
* Normalize the color to RGB values between 0-1
* @method normalize
* @memberof axe.commons.color.Color
* @instance
* @return {Color} A new color instance with RGB values between 0-1
*/
normalize() {
straker marked this conversation as resolved.
Show resolved Hide resolved
return new Color(
this.red / 255,
this.green / 255,
this.blue / 255,
this.alpha
);
Copy link
Contributor

Choose a reason for hiding this comment

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

This object is broken. That's the issue I mentioned in the other PR too. Color is built to accept 0-255. A bunch of methods on this class don't work correctly if you pass it 0-1. When we're in 0-1 land we need different properties. We can do that with getter/setters on this class, or we could create a separate object or class to represent that.

Suggested change
return new Color(
this.red / 255,
this.green / 255,
this.blue / 255,
this.alpha
);
return {
floatRed: this.red / 255,
floatGreen: this.green / 255,
floatBlue: this.blue / 255,
floatAlpha: this.alpha
);

Copy link
Contributor Author

@straker straker Mar 11, 2024

Choose a reason for hiding this comment

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

Decided to mimic how Colorjs.io does this by using color coordinates (mapped to r, g, b), also called color channels. I also learned that Babel supports private properties and functions. Just had to up our eslint version to allow it.

}

/**
* Scale the color by a value
* @method scale
* @memberof axe.commons.color.Color
* @instance
* @param {number} value The value to scale by
* @return {Color} A new color instance
*/
scale(value) {
return new Color(
this.red * value,
this.green * value,
this.blue * value,
this.alpha
);
}

/**
* Add a value to the color
* @method add
* @memberof axe.commons.color.Color
* @instance
* @param {number} value The value to add
* @return {Color} A new color instance
*/
add(value) {
return new Color(
this.red + value,
this.green + value,
this.blue + value,
this.alpha
);
}

/**
* Get the luminosity of a color. Color should be normalized before calling.
* 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.red + 0.59 * this.green + 0.11 * this.blue;
}

/**
* Set the luminosity of a color. Color should be normalized before calling.
* 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. Color should be normalized before calling.
* 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.red, this.green, this.blue) -
Math.min(this.red, this.green, this.blue)
);
}

/**
* Set the saturation of a color. Color should be normalized before calling.
* 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: 'red', value: C.red },
{ name: 'green', value: C.green },
{ name: 'blue', value: C.blue }
];

// 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.red, C.green, C.blue);
const x = Math.max(C.red, C.green, C.blue);

if (n < 0) {
C.red = L + ((C.red - L) * L) / (L - n);
C.green = L + ((C.green - L) * L) / (L - n);
C.blue = L + ((C.blue - L) * L) / (L - n);
}

if (x > 1) {
C.red = L + ((C.red - L) * (1 - L)) / (x - L);
C.green = L + ((C.green - L) * (1 - L)) / (x - L);
C.blue = L + ((C.blue - L) * (1 - L)) / (x - L);
}

return C;
}
}

// clamp a value between two numbers (inclusive)
Expand Down
176 changes: 176 additions & 0 deletions test/commons/color/color.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ describe('color.Color', () => {
assert.equal(c1.alpha, 1);
});

it('can be constructed from a Color', () => {
const c1 = new Color(4, 3, 2, 0.5);
const c2 = new Color(c1);
assert.equal(c2.red, 4);
assert.equal(c2.green, 3);
assert.equal(c2.blue, 2);
assert.equal(c2.alpha, 0.5);
});

it('has a toJSON method', () => {
const c1 = new Color(255, 128, 0);
assert.deepEqual(c1.toJSON(), {
Expand Down Expand Up @@ -413,4 +422,171 @@ describe('color.Color', () => {
assert.isTrue(lBlue > lBlack);
});
});

describe('normalize', () => {
it('normalizes RGB between 0-1', () => {
const black = new Color(0, 0, 0, 1).normalize();
const white = new Color(255, 255, 255, 1).normalize();
const darkyellow = new Color(128, 128, 0, 1).normalize();

assert.deepEqual(black.toJSON(), { red: 0, green: 0, blue: 0, alpha: 1 });
assert.deepEqual(white.toJSON(), { red: 1, green: 1, blue: 1, alpha: 1 });
assert.deepEqual(darkyellow.toJSON(), {
red: 0.5019607843137255,
green: 0.5019607843137255,
blue: 0,
alpha: 1
});
});

it('returns a new Color', () => {
const black = new Color(0, 0, 0, 1);
const nBlack = black.normalize();
assert.notEqual(black, nBlack);
});
});

describe('scale', () => {
it('scales RGB by the value', () => {
const black = new Color(0, 0, 0, 1).scale(2);
const white = new Color(127.5, 127.5, 127.5, 1).scale(2);
const darkyellow = new Color(32, 32, 0, 0.5).scale(4);

assert.deepEqual(black.toJSON(), { red: 0, green: 0, blue: 0, alpha: 1 });
assert.deepEqual(white.toJSON(), {
red: 255,
green: 255,
blue: 255,
alpha: 1
});
assert.deepEqual(darkyellow.toJSON(), {
red: 128,
green: 128,
blue: 0,
alpha: 0.5
});
});

it('returns a new Color', () => {
const black = new Color(0, 0, 0, 1);
const nBlack = black.scale(2);
assert.notEqual(black, nBlack);
});
});

describe('add', () => {
it('adds the value to RGB', () => {
const black = new Color(-2, -2, -2, 1).add(2);
const white = new Color(250, 250, 250, 1).add(5);
const darkyellow = new Color(32, 32, -96, 0.5).add(96);

assert.deepEqual(black.toJSON(), { red: 0, green: 0, blue: 0, alpha: 1 });
assert.deepEqual(white.toJSON(), {
red: 255,
green: 255,
blue: 255,
alpha: 1
});
assert.deepEqual(darkyellow.toJSON(), {
red: 128,
green: 128,
blue: 0,
alpha: 0.5
});
});

it('returns a new Color', () => {
const black = new Color(0, 0, 0, 1);
const nBlack = black.add(2);
assert.notEqual(black, nBlack);
});
});

describe('getLuminosity', () => {
it('returns luminosity of the Color', () => {
const L = new Color(128, 128, 0, 1).normalize().getLuminosity();
assert.equal(L, 0.44674509803921564);
});
});

describe('setLuminosity', () => {
it('sets the luminosity of the Color', () => {
const color = new Color(0, 0, 0, 1).normalize().setLuminosity(0.5);
assert.deepEqual(color.toJSON(), {
red: 0.5,
green: 0.5,
blue: 0.5,
alpha: 1
});
});

it('returns a new Color', () => {
const black = new Color(0, 0, 0, 1);
const nBlack = black.setLuminosity(0.5);
assert.notEqual(black, nBlack);
});
});

describe('getSaturation', () => {
it('returns the saturation of the Color', () => {
const s = new Color(255, 128, 200, 1).normalize().getSaturation();
assert.equal(s, 0.4980392156862745);
});
});

describe('setSaturation', () => {
it('sets the saturation of the Color', () => {
const color = new Color(128, 100, 0, 1).normalize().setSaturation(0.8);
assert.deepEqual(color.toJSON(), {
red: 0.8,
green: 0.625,
blue: 0,
alpha: 1
});
});

it('returns a new Color', () => {
const black = new Color(0, 0, 0, 1);
const nBlack = black.setSaturation(0.5);
assert.notEqual(black, nBlack);
});
});

describe('clip', () => {
it('clips to the lower bound', () => {
const color = new Color(255, 0, -1, 1).normalize().clip();
assert.deepEqual(color.toJSON(), {
red: 0.9909493297254295,
green: 0.003870895819239939,
blue: 0,
alpha: 1
});
});

it('clips to the upper bound', () => {
const color = new Color(255, 0, 256, 1).normalize().clip();
assert.deepEqual(color.toJSON(), {
red: 0.9961043436801178,
green: 0.002711982110142841,
blue: 1,
alpha: 1
});
});

it('clips both the lower and upper bounds', () => {
const color = new Color(-1, 0, 256, 1).normalize().clip();
assert.deepEqual(color.toJSON(), {
red: 0.00047889410870861904,
green: 0.004247986549875488,
blue: 0.9691356514885925,
alpha: 1
});
});

it('returns a new Color', () => {
const black = new Color(0, 0, 0, 1);
const nBlack = black.clip();
assert.notEqual(black, nBlack);
});
});
});
Loading