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

Refactor XYZ color space into its own module. #84

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export {default as color, rgb, hsl} from "./color.js";
export {default as lab, hcl, lch, gray} from "./lab.js";
export {default as cubehelix} from "./cubehelix.js";
export {default as xyz} from "./xyz.js";
40 changes: 10 additions & 30 deletions src/lab.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import define, {extend} from "./define.js";
import {Color, rgbConvert, Rgb} from "./color.js";
import {Color} from "./color.js";
import {degrees, radians} from "./math.js";
import {Xyz, xyzConvert} from "./xyz.js";

// https://observablehq.com/@mbostock/lab-and-rgb
const K = 18,
Xn = 0.96422,
Yn = 1,
Zn = 0.82521,
t0 = 4 / 29,
t1 = 6 / 29,
t2 = 3 * t1 * t1,
Expand All @@ -15,15 +13,10 @@ const K = 18,
function labConvert(o) {
if (o instanceof Lab) return new Lab(o.l, o.a, o.b, o.opacity);
if (o instanceof Hcl) return hcl2lab(o);
if (!(o instanceof Rgb)) o = rgbConvert(o);
var r = rgb2lrgb(o.r),
g = rgb2lrgb(o.g),
b = rgb2lrgb(o.b),
y = xyz2lab((0.2225045 * r + 0.7168786 * g + 0.0606169 * b) / Yn), x, z;
if (r === g && g === b) x = z = y; else {
x = xyz2lab((0.4360747 * r + 0.3850649 * g + 0.1430804 * b) / Xn);
z = xyz2lab((0.0139322 * r + 0.0971045 * g + 0.7141733 * b) / Zn);
}
if (!(o instanceof Xyz)) o = xyzConvert(o);
var x = xyz2lab(o.x),
y = xyz2lab(o.y),
z = xyz2lab(o.z);
return new Lab(116 * y - 16, 500 * (x - y), 200 * (y - z), o.opacity);
}

Expand Down Expand Up @@ -53,15 +46,10 @@ define(Lab, lab, extend(Color, {
var y = (this.l + 16) / 116,
x = isNaN(this.a) ? y : y + this.a / 500,
z = isNaN(this.b) ? y : y - this.b / 200;
x = Xn * lab2xyz(x);
y = Yn * lab2xyz(y);
z = Zn * lab2xyz(z);
return new Rgb(
lrgb2rgb( 3.1338561 * x - 1.6168667 * y - 0.4906146 * z),
lrgb2rgb(-0.9787684 * x + 1.9161415 * y + 0.0334540 * z),
lrgb2rgb( 0.0719453 * x - 0.2289914 * y + 1.4052427 * z),
this.opacity
);
x = lab2xyz(x);
y = lab2xyz(y);
z = lab2xyz(z);
return new Xyz(x, y, z, this.opacity).rgb();
}
}));

Expand All @@ -73,14 +61,6 @@ function lab2xyz(t) {
return t > t1 ? t * t * t : t2 * (t - t0);
}

function lrgb2rgb(x) {
return 255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055);
}

function rgb2lrgb(x) {
return (x /= 255) <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
}

function hclConvert(o) {
if (o instanceof Hcl) return new Hcl(o.h, o.c, o.l, o.opacity);
if (!(o instanceof Lab)) o = labConvert(o);
Expand Down
73 changes: 73 additions & 0 deletions src/xyz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import define, {extend} from "./define.js";
import {Color, rgbConvert, Rgb} from "./color.js";

/* CIE XYZ D50 */

const Xn = 0.96422,
Yn = 1,
Zn = 0.82521,
// For converting from lRGB to XYZ.
ry = 0.2225045,
gy = 0.7168786,
by = 0.0606169,
rx = 0.4360747,
gx = 0.3850649,
bx = 0.1430804,
rz = 0.0139322,
gz = 0.0971045,
bz = 0.7141733,
// For converting from XYZ to lRGB.
xr = 3.1338561,
yr = -1.6168667,
zr = -0.4906146,
xg = -0.9787684,
yg = 1.9161415,
zg = 0.0334540,
xb = 0.0719453,
yb = -0.2289914,
zb = 1.4052427;

function rgb2lrgb(x) {
return (x /= 255) <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
}

export function xyzConvert(o) {
if (o instanceof Xyz) return new Xyz(o.x, o.y, o.z, o.opacity);
if (!(o instanceof Rgb)) o = rgbConvert(o);
var r = rgb2lrgb(o.r),
g = rgb2lrgb(o.g),
b = rgb2lrgb(o.b),
y = ((ry * r) + (gy * g) + (by * b)) / Yn, x, z;
if (r === g && g === b) x = z = y; else {
x = ((rx * r) + (gx * g) + (bx * b)) / Xn,
z = ((rz * r) + (gz * g) + (bz * b)) / Zn;
}
return new Xyz(x, y, z, o.opacity);
}

export default function xyz(x, y, z, opacity) {
return arguments.length === 1 ? xyzConvert(x) : new Xyz(x, y, z, opacity == null ? 1 : opacity);
}

export function Xyz(x, y, z, opacity) {
this.x = +x;
this.y = +y;
this.z = +z;
this.opacity = +opacity;
}

function lrgb2rgb(x) {
return 255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055);
}

define(Xyz, xyz, extend(Color, {
rgb: function() {
var x = Xn * this.x,
y = Yn * this.y,
z = Zn * this.z,
r = (xr * x) + (yr * y) + (zr * z),
g = (xg * x) + (yg * y) + (zg * z),
b = (xb * x) + (yb * y) + (zb * z);
return new Rgb(lrgb2rgb(r), lrgb2rgb(g), lrgb2rgb(b), this.opacity);
}
}));
111 changes: 111 additions & 0 deletions test/xyz-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
var tape = require("tape"),
color = require("../");

require("./xyzEqual");

tape("xyz(…) returns an instance of xyz and color", function(test) {
var c = color.xyz(0.45226, 0.222505, 0.0168832);
test.ok(c instanceof color.xyz);
test.ok(c instanceof color.color);
test.end();
});

tape("xyz(…) exposes x, y and z channel values and opacity", function(test) {
test.xyzEqual(color.xyz('rgba(170, 187, 204, 0.4)'), 0.4698509167733117, 0.48228463612237665, 0.5878409472299186, 0.4);
test.end();
});

tape("xyz.toString() converts to RGB formats as rgb(…) and rgba(…)", function(test) {
test.equal(color.xyz("#abcdef") + "", "rgb(171, 205, 239)");
test.equal(color.xyz("moccasin") + "", "rgb(255, 228, 181)");
test.equal(color.xyz("hsl(60, 100%, 20%)") + "", "rgb(102, 102, 0)");
test.equal(color.xyz("hsla(60, 100%, 20%, 0.4)") + "", "rgba(102, 102, 0, 0.4)");
test.equal(color.xyz("rgb(12, 34, 56)") + "", "rgb(12, 34, 56)");
test.equal(color.xyz(color.rgb(12, 34, 56)) + "", "rgb(12, 34, 56)");
test.equal(color.xyz(color.hsl(60, 1, 0.2)) + "", "rgb(102, 102, 0)");
test.equal(color.xyz(color.hsl(60, 1, 0.2, 0.4)) + "", "rgba(102, 102, 0, 0.4)");
test.end();
});

tape("xyz.toString() reflects x, y and z channel values and opacity", function(test) {
var c = color.xyz("#abc");
c.x += 0.1, c.y += 0.2, c.z -= 0.3, c.opacity = 0.4;
test.equal(c + "", "rgba(188, 228, 128, 0.4)");
test.end();
});

tape("xyz.toString() treats undefined channel values as 0", function(test) {
test.equal(color.xyz("invalid") + "", "rgb(0, 0, 0)");
test.equal(color.xyz(NaN, 0, 0) + "", "rgb(0, 0, 0)");
test.equal(color.xyz(0, NaN, 0) + "", "rgb(0, 0, 0)");
test.equal(color.xyz(50, 0, NaN) + "", "rgb(0, 0, 0)");
test.equal(color.xyz(0, NaN, NaN) + "", "rgb(0, 0, 0)");
test.end();
});

tape("xyz.toString() treats undefined opacity as 1", function(test) {
var c = color.xyz("#abc");
c.opacity = NaN;
test.equal(c + "", "rgb(170, 187, 204)");
test.end();
});

tape("xyz(x, y, z) coerces channel values and opacity to numbers", function(test) {
test.xyzEqual(color.xyz("50", "4", "-5", "0.4"), 50, 4, -5, 0.4);
test.end();
});

tape("xyz(x, y, z) allows undefined channel values", function(test) {
test.xyzEqual(color.xyz(undefined, NaN, "foo"), NaN, NaN, NaN, 1);
test.xyzEqual(color.xyz(undefined, 4, -5), NaN, 4, -5, 1);
test.xyzEqual(color.xyz(42, undefined, -5), 42, NaN, -5, 1);
test.xyzEqual(color.xyz(42, 4, undefined), 42, 4, NaN, 1);
test.end();
});

tape("xyz(x, y, z, opacity) converts undefined opacity to 1", function(test) {
test.xyzEqual(color.xyz(10, 20, 30, null), 10, 20, 30, 1);
test.xyzEqual(color.xyz(10, 20, 30, undefined), 10, 20, 30, 1);
test.end();
});

tape("xyz(format) parses the specified format and converts to Lab", function(test) {
test.xyzEqual(color.xyz("#abcdef"), 0.5560644339678268, 0.580585904328259, 0.8257285476267742, 1);
test.xyzEqual(color.xyz("#abc"), 0.4698509167733117, 0.48228463612237665, 0.5878409472299186, 1);
test.xyzEqual(color.xyz("rgb(12, 34, 56)"), 0.013919161701096703, 0.014682610006085534, 0.03616945899178624, 1);
test.xyzEqual(color.xyz("rgb(12%, 34%, 56%)"), 0.08449107282885772, 0.08742136462174761, 0.24835363519957995, 1);
test.xyzEqual(color.xyz("rgba(12%, 34%, 56%, 0.4)"), 0.08449107282885772, 0.08742136462174761, 0.24835363519957995, 0.4);
test.xyzEqual(color.xyz("hsl(60,100%,20%)"), 0.11315201967743199, 0.12481425579302234, 0.017878188533676058, 1);
test.xyzEqual(color.xyz("hsla(60,100%,20%,0.4)"), 0.11315201967743199, 0.12481425579302234, 0.017878188533676058, 0.4);
test.xyzEqual(color.xyz("aliceblue"), 0.9173388658755715, 0.9274237160306397, 0.9906132612270094, 1);
test.end();
});

tape("xyz(format) returns undefined channel values for unknown formats", function(test) {
test.xyzEqual(color.xyz("invalid"), NaN, NaN, NaN, NaN);
test.end();
});

tape("xyz(xyz) copies a Xyz color", function(test) {
var c1 = color.xyz(0.5, 1.2, -0.25, 0.4),
c2 = color.xyz(c1);
test.xyzEqual(c1, 0.5, 1.2, -0.25, 0.4);
c1.x = c1.y = c1.z = c1.opacity = 0;
test.xyzEqual(c1, 0, 0, 0, 0);
test.xyzEqual(c2, 0.5, 1.2, -0.25, 0.4);
test.end();
});

tape("xyz(rgb) converts from RGB", function(test) {
test.xyzEqual(color.xyz(color.rgb(255, 0, 0, 0.4)), 0.4522564352533654, 0.2225045, 0.016883217605215644, 0.4);
test.end();
});

tape("xyz(color) converts from another colorspace via color.rgb()", function(test) {
function TestColor() {}
TestColor.prototype = Object.create(color.color.prototype);
TestColor.prototype.rgb = function() { return color.rgb(12, 34, 56, 0.4); };
TestColor.prototype.toString = function() { throw new Error("should use rgb, not toString"); };
test.xyzEqual(color.xyz(new TestColor), 0.013919161701096703, 0.014682610006085534, 0.03616945899178624, 0.4);
test.end();
});
19 changes: 19 additions & 0 deletions test/xyzEqual.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
var tape = require("tape"),
color = require("../");

function floatEqual(got, want, epsilon=1e-6) {
return isNaN(want) ? (isNaN(got) && got !== got) : (Math.abs(want - got) <= epsilon);
}

tape.Test.prototype.xyzEqual = function(actual, x, y, z, opacity) {
this._assert(actual instanceof color.xyz
&& floatEqual(actual.x, x)
&& floatEqual(actual.y, y)
&& floatEqual(actual.z, z)
&& floatEqual(actual.opacity, opacity), {
message: "should be equal",
operator: "xyzEqual",
actual: [actual.x, actual.y, actual.z, actual.opacity],
expected: [x, y, z, opacity],
});
};