diff --git a/src/index.js b/src/index.js index 831cf52..0dbc915 100644 --- a/src/index.js +++ b/src/index.js @@ -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"; diff --git a/src/lab.js b/src/lab.js index 645e1a5..1031c8f 100644 --- a/src/lab.js +++ b/src/lab.js @@ -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, @@ -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); } @@ -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(); } })); @@ -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); diff --git a/src/xyz.js b/src/xyz.js new file mode 100644 index 0000000..0aa6578 --- /dev/null +++ b/src/xyz.js @@ -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); + } +})); diff --git a/test/xyz-test.js b/test/xyz-test.js new file mode 100644 index 0000000..59a58aa --- /dev/null +++ b/test/xyz-test.js @@ -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(); +}); diff --git a/test/xyzEqual.js b/test/xyzEqual.js new file mode 100644 index 0000000..784ea33 --- /dev/null +++ b/test/xyzEqual.js @@ -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], + }); +};