Chromatic Space
I’ve been meaning to write up some work I’ve done over the past couple of years out of a passion for working in TypeScript. I build several libraries that tend to get moved or copied between projects and should really be published for use by others. I wanted to replicate some work I did with color palettes this week and pulled in some utility classes I used but which are not part of the palette project per se, which I’ll write about in a separate blog.
I’ll cover four classes which interact fairly closely with each other because I wanted to make it easy to convert between color spaces. An additional class pulls together the actual conversion code so it can more easily be shared by the other classes. We’ll cover the color spaces first.
You’ll notice some patterns that cross the color space classes. For example, constructors take relevant arguments and default any alpha space to the maximum value so you can assume opacity if you don’t care about that. Default values produce a white color object.
We make heavy use of property function for conversions, where you can assign an array or build one dynamically with the values you might need in different circumstances. We also provide integer, with and without the alpha value. The RGB clas, for example, provides rgbaInt and rgbInt properties you can use to set or get the relevant value.
import Convert from "./Convert";
import HSB from "./HSB";
import HSL from "./HSL";
export default class RGB {
constructor(
public r: number = 0xFF,
public g: number = 0xFF,
public b: number = 0xFF,
public a: number = 0xFF) {
}
// TO/FROM [r, g, b, a] ARRAY
get array(): number[] {
return [this.r, this.g, this.b, this.a];
}
set array(array: number[]) {
if (array.length > 0) {
this.r = array[0];
}
if (array.length > 1) {
this.g = array[1];
}
if (array.length > 2) {
this.b = array[2];
}
if (array.length > 3) {
this.a = array[3];
}
}
// TO/FROM RGBA INTEGER
get rgbaInt() {
return this.r << 24 | this.g << 16 | this.b << 8 | this.a;
}
set rgbaInt(rgba: number) {
this.r = rgba >> 24 & 0xFF;
this.g = rgba >> 16 & 0xFF;
this.b = rgba >> 8 & 0xFF;
this.a = rgba & 0xFF;
}
// TO/FROM RGB INTEGER
get rgbInt() {
return this.r << 16 | this.g << 8 | this.b;
}
set rgbInt(rgb: number) {
this.r = rgb >> 16 & 0xFF;
this.g = rgb >> 8 & 0xFF;
this.b = rgb & 0xFF;
}
// TO/FROM RGB (copy)
get rgb(): RGB {
return new RGB(this.r, this.g, this.b, this.a);
}
set rgb(rgb: RGB) {
this.r = rgb.r;
this.g = rgb.g;
this.b = rgb.b;
this.a = rgb.a;
}
// TO/FROM HSB
get hsb(): HSB {
return Convert.rgb2hsb(this);
}
set hsb(hsb: HSB) {
this.rgb = Convert.hsb2rgb(hsb);
}
// TO/FROM HSL
get hsl(): HSL {
return Convert.hsb2hsl(this.hsb);
}
set hsl(hsl: HSL) {
this.hsb = Convert.hsl2hsb(hsl);
}
// WEB
css() {
var hex = this.rgbInt.toString(16);
while (hex.length < 6)
{
hex = "0" + hex;
}
return `#` + hex;
}
}
We provide conversion properties to and from each of the other color space objects for convenience. In addition, we provide a css function that can be used to construct a string suitable for use in CSS code. In non RGB cases, we return an RGB string representation by first converting to an RGB object because that is the most compact string representation and easiest to share.
The HSB class adds a few useful methods for working with color complements and color wheel-based adjustments. There are a number of rotate functions that shift the color space along a single dimension, the hue by default but additional methods are provided to support shifting along the brightness and saturation dimensions.
import Convert from "./Convert";
import RGB from "./RGB";
import HSL from "./HSL";
export default class HSB {
constructor(
public h: number = 1,
public s: number = 1,
public b: number = 1,
public a: number = 1) {
}
// TO/FROM [h, s, b, a] ARRAYS
get array(): number[] {
return [this.h, this.h, this.b, this.a];
}
set array(array: number[]) {
if (array.length > 0) {
this.h = array[0];
}
if (array.length > 1) {
this.s = array[1];
}
if (array.length > 2) {
this.b = array[2];
}
if (array.length > 3) {
this.a = array[3];
}
}
// TO/FROM HSBA INTEGER
get hsbaInt() {
return Math.floor(this.h * 255) << 24
| Math.floor(this.s * 255) << 16
| Math.floor(this.b * 255) << 8
| Math.floor(this.a * 255);
}
set hsbaInt(hsba: number) {
this.h = ((hsba >> 24) & 0xFF) / 255;
this.s = ((hsba >> 16) & 0xFF) / 255;
this.b = ((hsba >> 8) & 0xFF) / 255;
this.a = (hsba & 0xFF) / 255;
}
// TO/FROM HSB INTEGER
get hsbInt() {
return Math.floor(this.h * 255) << 16
| Math.floor(this.s * 255) << 8
| Math.floor(this.b * 255);
}
set hsbInt(hsb: number) {
this.h = ((hsb >> 16) & 0xFF) / 255;
this.s = ((hsb >> 8) & 0xFF) / 255;
this.b = (hsb & 0xFF) / 255;
}
// TO CSS
css() {
return this.rgb.css();
}
// TO/FROM RGB
get rgb(): RGB {
return Convert.hsb2rgb(this);
}
set rgb(rgb: RGB) {
this.hsb = Convert.rgb2hsb(rgb);
}
// TO/FROM HSB (copy)
get hsb(): HSB {
return new HSB(this.h, this.s, this.b, this.a);
}
set hsb(hsb: HSB) {
this.h = hsb.h;
this.s = hsb.s;
this.b = hsb.b;
this.a = hsb.a;
}
// TO/FROM HSL
get hsl(): HSL {
return Convert.hsb2hsl(this);
}
set hsl(hsl: HSL) {
this.hsb = Convert.hsl2hsb(hsl);
}
// UTILITIES
rotate(fraction: number) {
var value = this.h + fraction;
while (value > 1) value -= 1;
while (value < 0) value += 1;
return new HSB(value, this.s, this.b);
}
around(fraction: number, countOnEachSide: number): HSB[] {
var spread: HSB[] = [];
for (var i = -countOnEachSide; i <= countOnEachSide; i++) {
spread.push(this.rotate(i * fraction));
}
return spread;
}
complement() {
return this.rotate(0.5);
}
rotateS(fraction: number) {
var value = this.s + fraction;
while (value > 1) value -= 1;
while (value < 0) value += 1;
return new HSB(this.h, value, this.b);
}
aroundS(fraction: number, countOnEachSide: number): HSB[] {
var spread: HSB[] = [];
for (var i = -countOnEachSide; i <= countOnEachSide; i++) {
spread.push(this.rotateS(i * fraction));
}
return spread;
}
rotateB(fraction: number) {
var value = this.b + fraction;
while (value > 1) value -= 1;
while (value < 0) value += 1;
return new HSB(this.h, this.s, value);
}
aroundB(fraction: number, countOnEachSide: number): HSB[] {
var spread: HSB[] = [];
for (var i = -countOnEachSide; i <= countOnEachSide; i++) {
spread.push(this.rotateB(i * fraction));
}
return spread;
}
}
The complement of a color is determined by shifting the hue 180 degrees, or 0.5 of the possible values. Rotations are wrapped to keep them normalized between 0 and 1. The around function allows you to generate values around a given center along the specified dimension. A rotation fraction and the count of elements on each side is provided and an array returns the colors you can then use as you see fit.
The HSL class is a variant on HSB with a slighly different color space. Because this space has slighly different characteristics, it’s useful to have it represented as a separate class.
import Convert from "./Convert";
import RGB from "./RGB";
import HSB from "./HSB";
export default class HSL {
constructor(
public h: number = 1,
public s: number = 1,
public l: number = 1,
public a: number = 1) {
}
// TO/FROM [h, s, l, a] ARRAYS
get array(): number[] {
return [this.h, this.h, this.l, this.a];
}
set array(array: number[]) {
if (array.length > 0) {
this.h = array[0];
}
if (array.length > 1) {
this.s = array[1];
}
if (array.length > 2) {
this.l = array[2];
}
if (array.length > 3) {
this.a = array[3];
}
}
// TO/FROM HSLA INTEGER
get hslaInt() {
return Math.floor(this.h * 255) << 24
| Math.floor(this.s * 255) << 16
| Math.floor(this.l * 255) << 8
| Math.floor(this.a * 255);
}
set hslaInt(hsla: number) {
this.h = ((hsla >> 24) & 0xFF) / 255;
this.s = ((hsla >> 16) & 0xFF) / 255;
this.l = ((hsla >> 8) & 0xFF) / 255;
this.a = (hsla & 0xFF) / 255;
}
// TO/FROM HSL INTEGER
get hslInt() {
return Math.floor(this.h * 255) << 16
| Math.floor(this.s * 255) << 8
| Math.floor(this.l * 255);
}
set hslInt(hsl: number) {
this.h = ((hsl >> 16) & 0xFF) / 255;
this.s = ((hsl >> 8) & 0xFF) / 255;
this.l = (hsl & 0xFF) / 255;
}
// TO CSS
css() {
return this.rgb.css();
}
// TO/FROM RGB
get rgb(): RGB {
return Convert.hsb2rgb(this.hsb);
}
set rgb(rgb: RGB) {
this.hsb = Convert.rgb2hsb(rgb);
}
// TO/FROM RGB
get hsb(): HSB {
return Convert.hsl2hsb(this);
}
set hsb(hsb: HSB) {
this.hsl = Convert.hsb2hsl(hsb);
}
// TO/FROM HSL (copy)
get hsl(): HSL {
return new HSL(this.h, this.s, this.l, this.a);
}
set hsl(hsl: HSL) {
this.h = hsl.h;
this.s = hsl.s;
this.l = hsl.l;
this.a = hsl.a;
}
// UTILITIES
rotate(fraction: number) {
return new HSL((this.h + fraction) % 1, this.s, this.l);
}
around(fraction: number, countOnEachSide: number): HSL[] {
var spread: HSL[] = [];
for (var i = -countOnEachSide; i < countOnEachSide; i++) {
spread.push(this.rotate(i * fraction));
}
return spread;
}
complement() {
return this.rotate(0.5);
}
}
Finally, the Convert class gives us some functions used in converting between color spaces. These are used in two of three color spaces on average and soare best kept separated to avoid duplication.
import HSB from "./HSB";
import HSL from "./HSL";
import RGB from "./RGB";
export default class Convert {
// STATIC CONVERSION METHODS, SEE:
// http://stackoverflow.com/questions/17242144/javascript-convert-hsb-hsv-color-to-rgb-accurately
static rgb2hsb(rgb: RGB): HSB {
var max = Math.max(rgb.r, rgb.g, rgb.b);
var min = Math.min(rgb.r, rgb.g, rgb.b);
var range = max - min;
var brightness = max / 255;
var hue = 0;
switch (max) {
case min:
hue = 0;
break;
case rgb.r:
hue = (rgb.g - brightness) + range * (rgb.g < brightness ? 6 : 0);
hue /= 6 * range;
break;
case rgb.g:
hue = (brightness - rgb.r) + range * 2;
hue /= 6 * range;
break;
case rgb.b:
hue = (rgb.r - rgb.g) + range * 4;
hue /= 6 * range;
break;
}
var saturation = (max === 0 ? 0 : range / max);
return new HSB(hue, saturation, brightness);
}
static hsb2rgb(hsb: HSB): RGB {
var r = 0, g = 0, b = 0;
if (hsb.s == 0) {
r = g = b = Math.floor(hsb.b * 255.0 + 0.5);
}
else {
var h = (hsb.h - Math.floor(hsb.h)) * 6.0;
var f = h - Math.floor(h);
var p = hsb.b * (1.0 - hsb.s);
var q = hsb.b * (1.0 - hsb.s * f);
var t = hsb.b * (1.0 - hsb.s * (1.0 - f));
switch (Math.floor(h)) {
case 0:
r = hsb.b * 255.0 + 0.5;
g = t * 255.0 + 0.5;
b = p * 255.0 + 0.5;
break;
case 1:
r = q * 255.0 + 0.5;
g = hsb.b * 255.0 + 0.5;
b = p * 255.0 + 0.5;
break;
case 2:
r = p * 255.0 + 0.5;
g = hsb.b * 255.0 + 0.5;
b = t * 255.0 + 0.5;
break;
case 3:
r = p * 255.0 + 0.5;
g = q * 255.0 + 0.5;
b = hsb.b * 255.0 + 0.5;
break;
case 4:
r = t * 255.0 + 0.5;
g = p * 255.0 + 0.5;
b = hsb.b * 255.0 + 0.5;
break;
case 5:
r = hsb.b * 255.0 + 0.5;
g = p * 255.0 + 0.5;
b = q * 255.0 + 0.5;
break;
}
}
return new RGB(Math.floor(r), Math.floor(g), Math.floor(b));
}
static hsb2hsl(hsb: HSB): HSL {
var h = hsb.h;
var s = hsb.s * hsb.b;
var l = (2 - hsb.s) * hsb.b;
s /= (l <= 1) ? l : 2 - l;
l /= 2;
return new HSL(h, s, l);
}
static hsl2hsb(hsl: HSL): HSB {
var h = hsl.h;
var b = hsl.l * 2;
var s = hsl.s * (hsl.l <= 1 ? hsl.l : 2 - hsl.l);
var b = (hsl.l + hsl.s) / 2;
s = (2 * s) / (hsl.l + s);
return new HSB(h, s, b);
}
}
If you find these classes ineresting, let me know. I expect to package these as a node module at some point and put everything up on GitHub, as time permits. I’ve used these in a couple of projects already and have chased down any bugs I ran across but I think it would also be useful to write a bunch of automated unit tests/specs before releasing them more widely.