Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

additional colour space for d3 #579

Closed
wants to merge 30 commits into from

3 participants

@justincormack

Here is a patch for adding CIE Lab colourspace to d3.

There is a gist up http://bl.ocks.org/1958838 showing the improved perceptual colours for brighter and darker, will add some more for the hues.

@justincormack

Ah I just saw that there is a pull request for HSV too... will add interpolators as per the comment there.

@mbostock
Owner

Nice work, and thank for you the contribution!

Related #183 - d3.lab
Related #517 - d3.hsv
Related https://github.com/jrus/chromatist

Do you have any recommendation as to how to choose between XYZ, CIE Lab or CIE LCh? Is there a pressing need for all three, or did you include them for completeness' sake? My main reservation here is feature creep. Still, given the interest I could be convinced of the usefulness of including at least one perceptually-based colorspace in core. (Probably CIE Lab?) The other colorspaces are useful, but I wonder if it might be better to have a d3-chromatist plugin that adds additional colorspaces on demand?

Implementation comment: if we have more than two color spaces, then having methods on every color space to convert to every other color space probably won't scale. Fortunately, it's also unnecessary because all of them must support a toString() method that returns the "#rrggbb" format (for browser compatibility); as long as all the constructors support parsing the same format and converting to the respective colorspace, then we only have to deal with conversion to and from RGB.

So, we shouldn't remove the existing methods for backwards-compatibility, but you should only have to implement an rgb() and a toString() method on the new colorspaces, and leave the conversion to the constructors. For example, to go from xyz to lab, you might say myLab = d3.lab(myXyz) rather than myLab = myXyz.lab().

/cc @jrus @jheer @alex

@justincormack

XYZ was only really there as CIELab is defined as a transform from it. I don't think there is much of a use case for it directly, so will remove it.

The other two are just the linear and cylindrical versions of the same space. Some operations only really make sense in the linear CIELab, like interpolation, but other things like changing hue make more sense in cylindrical coordinates. One option would be to have one type but expose the cylindrical operations on it too. I think this is probably the nicest to work with, so will give it a try.

@justincormack

Ok so now XYZ is gone. CIELab now has hue() and chroma() methods that can return or set the hue and chroma, which is the only reason I had CIELch, so that is gone too. Added Lab interpolator. It is pretty small, so doesn't look too much like bloat in the core.

@justincormack

Some thoughts at this point:

  1. For compatibility with existing hsl, I should probably switch the chroma() function to scale as 0..1 not 0..100, and accept percentages. But then the lightness probably ought to be changed, or add a lightness() function. Will think about this a bit more.

  2. If you take the view that HSL and HSV are not colour spaces, just representations of RGB, then adding hue(), saturation(), lightness() and value() functions to d3.rgb would be an option, and then d3.hsl could just be a constructor for rgb objects and not an object of its own. This would simplify the situation to just two spaces, one device space and one perceptual, with a small amount of backwards compatibility breakage.

  3. Chromatist seems a bit complex for most use cases in d3.

@jrus

It’s quite helpful to be able to interpolate in terms of both L*C*h and L*a*b*. The former is useful when it’s a desirable feature to keep chroma high (for instance, when interpolating from red to blue, it might be nice to go through a colorful purple instead of a dull purplish-gray). As a precedent, ArcGIS allows the use of either in its “color ramp” pickers.

Regardless of which are supported, CIELAB → RGB transformations have to have some knowledge of gamut and some gamut mapping algorithm for out-of-gamut colors. The most obvious choices are (a) the nearest in-gamut point between a color and the grey of the same lightness, and (b) the nearest in-gamut point between a color and middle gray (L* = 50).

HSL/HSV/RGB are quite perniciously bad tools for visualizations (and anything with human input involved, really), and the documentation should clearly recommend against them.

Chroma (C*) should scale the same as a*/b*, or else it’s entirely confusing what it means. CIELAB has no “maximum” chroma, and 100 is not in any way special. It should not accept percentages.

You might be right that CIECAM02 is more complex than necessary for many D3 applications.

@jrus

One more thing that should really be clarified: I assume the CIELAB here is relative to a reference D65 white point? This is fine, but it should be pointed out in the docs that this makes for different output than Photoshop, which uses a chromatic adaptation transform (the Bradford CAT) to adapt XYZ colors from a source color space’s white point (e.g. sRGB’s D65) to D50, and then computes CIELAB coordinates based on that D50-adapted XYZ.

@justincormack

Thanks for those comments they are very helpful. Will work on the issues raised, should be able to add both interpolators for example.

@justincormack

Having used this as is for a bit, it is annoying not to have a way to do LCh initialization. Not sure whether to do an "lch(...)" style one like rgb has or go back to having an LCh colourspace. Doing interpolation across LCh will be a little slower without a colourspace for it too.

@mbostock
Owner

Would you be interested in exposing this as a D3 plugin? I've created a repository at d3/d3-plugins, and I could give you push access to submit your code there. To help you get started, I made some scaffolding of a d3.cie plugin to show you what I was imagining; you're of course welcome to propose an alternative structure, but I think it could fit in nicely.

@justincormack

Yeah sure that looks good, will see how it works out but certainly makes sense.

@mbostock
Owner

Great! I just gave you push privileges. Let me know if you want a hand.

@mbostock
Owner

I combined your code with @jheer's earlier implementation of Lab color and it is now available as the d3.cie plugin. Here's an example demonstrating Lab and LCH interpolation.

If you want to make any edits, go ahead, you have the powah!

@mbostock mbostock closed this
@justincormack

Will find some time to look at this soon! Been tied up in other projects but its not forgotten!

@jrus

Main thing that would be very helpful if people plan to start using this is some kind of gamut mapping algorithm. Otherwise, they’re going to do LCh interpolation from one bright color to another, and end up with all kinds of weird artifacts along the way. Otherwise, it’s nice that D3 is trying to put some better color spaces in. :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Feb 27, 2012
  1. @justincormack
Commits on Mar 2, 2012
  1. @justincormack

    add more tests

    justincormack authored
  2. @justincormack

    xyz to rgb conversion

    justincormack authored
  3. @justincormack

    xyz to hsl conversion

    justincormack authored
  4. @justincormack

    add assert.xyzEqual()

    justincormack authored
  5. @justincormack

    clean up tests a little

    justincormack authored
  6. @justincormack
  7. @justincormack
  8. @justincormack

    rgb and hsl conversions

    justincormack authored
  9. @justincormack
  10. @justincormack
  11. @justincormack
  12. @justincormack
  13. @justincormack

    start of cielch support

    justincormack authored
  14. @justincormack
Commits on Mar 4, 2012
  1. @justincormack
  2. @justincormack
  3. @justincormack
  4. @justincormack

    remove xyz colour space

    justincormack authored
  5. @justincormack
  6. @justincormack
  7. @justincormack
  8. @justincormack
  9. @justincormack

    remove cielch support

    justincormack authored
  10. @justincormack
  11. @justincormack
  12. @justincormack
  13. @justincormack

    add CIELab interpolator

    justincormack authored
  14. @justincormack
Commits on Mar 10, 2012
  1. @justincormack

    extra prototype removed

    justincormack authored
This page is out of date. Refresh to see the latest.
View
1  Makefile
@@ -76,6 +76,7 @@ d3.core.js: \
src/core/uninterpolate.js \
src/core/rgb.js \
src/core/hsl.js \
+ src/core/lab.js \
src/core/selection.js \
src/core/selection-select.js \
src/core/selection-selectAll.js \
View
149 d3.v2.js
@@ -1082,6 +1082,20 @@ d3.interpolateHsl = function(a, b) {
};
};
+d3.interpolateLab = function(a, b) {
+ a = d3.lab(a);
+ b = d3.lab(b);
+ var l0 = a.l,
+ a0 = a.a,
+ b0 = a.b,
+ l1 = b.l - l0,
+ a1 = b.a - a0,
+ b1 = b.b - b0;
+ return function(t) {
+ return d3_lab_rgb(l0 + l1 * t, a0 + a1 * t, b0 + b1 * t).toString();
+ };
+};
+
d3.interpolateArray = function(a, b) {
var x = [],
c = [],
@@ -1189,6 +1203,10 @@ d3_Rgb.prototype.hsl = function() {
return d3_rgb_hsl(this.r, this.g, this.b);
};
+d3_Rgb.prototype.lab = function() {
+ return d3_rgb_lab(this.r, this.g, this.b);
+};
+
d3_Rgb.prototype.toString = function() {
return "#" + d3_rgb_hex(this.r) + d3_rgb_hex(this.g) + d3_rgb_hex(this.b);
};
@@ -1270,6 +1288,42 @@ function d3_rgb_hsl(r, g, b) {
return d3_hsl(h, s, l);
}
+function d3_rgb_lab(r, g, b) {
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+ function v(r) {
+ return r > 0.04045 ? Math.pow(((r + 0.055) / 1.055), 2.4) : r / 12.92;
+ }
+
+ r = v(r) * 100;
+ g = v(g) * 100;
+ b = v(b) * 100;
+
+ var x = r * 0.4124 + g * 0.3576 + b * 0.1805,
+ y = r * 0.2126 + g * 0.7152 + b * 0.0722,
+ z = r * 0.0193 + g * 0.1192 + b * 0.9505;
+
+ x /= 95.047;
+ y /= 100.000;
+ z /= 108.883;
+
+ function w(x) {
+ return x > 0.008856 ? Math.pow(x, 1 / 3) : (7.787 * x) + (16 / 116);
+ }
+
+ x = w(x);
+ y = w(y);
+ z = w(z);
+
+ var l = y > 0.008856 ? (116 * y) - 16 : 903.3 * y,
+ a = 500 * (x - y),
+ b = 200 * (y - z);
+
+ return d3_lab(l, a, b);
+}
+
function d3_rgb_parseNumber(c) { // either integer or percentage
var f = parseFloat(c);
return c.charAt(c.length - 1) === "%" ? Math.round(f * 2.55) : f;
@@ -1491,6 +1545,101 @@ function d3_hsl_rgb(h, s, l) {
return d3_rgb(vv(h + 120), vv(h), vv(h - 120));
}
+d3.lab = function(l, a, b) {
+ return arguments.length === 1
+ ? (l instanceof d3_Lab ? d3_lab(l.l, l.a, l.b)
+ : d3_rgb_parse("" + l, d3_rgb, d3_hsl_rgb).lab())
+ : d3_lab(+l, +a, +b);
+};
+
+function d3_lab(l, a, b) {
+ return new d3_Lab(l, a, b);
+}
+
+function d3_Lab(l, a, b) {
+ this.l = l;
+ this.a = a;
+ this.b = b;
+}
+
+d3_Lab.prototype.rgb = function() {
+ return d3_lab_rgb(this.l, this.a, this.b);
+};
+
+d3_Lab.prototype.cielch = function() {
+ return d3_lab_cielch(this.l, this.a, this.b);
+};
+
+/* 18 chosen to correspond roughly to RGB brighter/darker */
+d3_Lab.prototype.brighter = function(k) {
+ return d3_lab(Math.min(100, this.l + 18 * (arguments.length ? k : 1)), this.a, this.b);
+};
+
+d3_Lab.prototype.darker = function(k) {
+ return d3_lab(Math.max(0, this.l - 18 * (arguments.length ? k : 1)), this.a, this.b);
+};
+
+d3_Lab.prototype.hue = function(h) {
+ var a = this.a,
+ b = this.b;
+
+ if (arguments.length) {
+ return d3_lab_lch(this.l, this.chroma(), h)
+ }
+
+ var h = Math.atan2(b, a);
+
+ return h > 0 ? (h / Math.PI) * 180 : 360 - (Math.abs(h) / Math.PI) * 180;
+}
+
+d3_Lab.prototype.chroma = function(c) {
+ var a = this.a,
+ b = this.b;
+
+ if (arguments.length) {
+ return d3_lab_lch(this.l, c, this.hue())
+ }
+
+ return Math.sqrt(a * a + b * b);
+}
+
+d3_Lab.prototype.toString = function() {
+ return this.rgb().toString();
+};
+
+function d3_lab_rgb(l, a, b) {
+ var y = (l + 16) / 116,
+ x = a / 500 + y,
+ z = y - b / 200;
+
+ function v(x) {
+ var p = x * x * x;
+ return p > 0.008856 ? p : (x - 16 / 116) / 7.787;
+ }
+
+ var x = v(x) * 0.95047,
+ y = v(y),
+ z = v(z) * 1.08883;
+
+ var r = x * 3.2406 + y * -1.5372 + z * -0.4986,
+ g = x * -0.9689 + y * 1.8758 + z * 0.0415,
+ b = x * 0.0557 + y * -0.2040 + z * 1.0570;
+
+ function w(r) {
+ r = r > 0.0031308 ? 1.055 * Math.pow(r, (1 / 2.4)) - 0.055 : 12.92 * r;
+ return Math.round(r * 255)
+ }
+
+ return d3_rgb(w(r), w(g), w(b));
+}
+
+function d3_lab_lch(l, c, h) {
+ var hr = h * (Math.PI / 180),
+ a = Math.cos(hr) * c,
+ b = Math.sin(hr) * c;
+ return d3_lab(l, a, b);
+}
+
function d3_selection(groups) {
d3_arraySubclass(groups, d3_selectionPrototype);
return groups;
View
8 d3.v2.min.js
4 additions, 4 deletions not shown
View
14 src/core/interpolate.js
@@ -175,6 +175,20 @@ d3.interpolateHsl = function(a, b) {
};
};
+d3.interpolateLab = function(a, b) {
+ a = d3.lab(a);
+ b = d3.lab(b);
+ var l0 = a.l,
+ a0 = a.a,
+ b0 = a.b,
+ l1 = b.l - l0,
+ a1 = b.a - a0,
+ b1 = b.b - b0;
+ return function(t) {
+ return d3_lab_rgb(l0 + l1 * t, a0 + a1 * t, b0 + b1 * t).toString();
+ };
+};
+
d3.interpolateArray = function(a, b) {
var x = [],
c = [],
View
91 src/core/lab.js
@@ -0,0 +1,91 @@
+d3.lab = function(l, a, b) {
+ return arguments.length === 1
+ ? (l instanceof d3_Lab ? d3_lab(l.l, l.a, l.b)
+ : d3_rgb_parse("" + l, d3_rgb, d3_hsl_rgb).lab())
+ : d3_lab(+l, +a, +b);
+};
+
+function d3_lab(l, a, b) {
+ return new d3_Lab(l, a, b);
+}
+
+function d3_Lab(l, a, b) {
+ this.l = l;
+ this.a = a;
+ this.b = b;
+}
+
+d3_Lab.prototype.rgb = function() {
+ return d3_lab_rgb(this.l, this.a, this.b);
+};
+
+/* 18 chosen to correspond roughly to RGB brighter/darker */
+d3_Lab.prototype.brighter = function(k) {
+ return d3_lab(Math.min(100, this.l + 18 * (arguments.length ? k : 1)), this.a, this.b);
+};
+
+d3_Lab.prototype.darker = function(k) {
+ return d3_lab(Math.max(0, this.l - 18 * (arguments.length ? k : 1)), this.a, this.b);
+};
+
+d3_Lab.prototype.hue = function(h) {
+ var a = this.a,
+ b = this.b;
+
+ if (arguments.length) {
+ return d3_lab_lch(this.l, this.chroma(), h)
+ }
+
+ var h = Math.atan2(b, a);
+
+ return h > 0 ? (h / Math.PI) * 180 : 360 - (Math.abs(h) / Math.PI) * 180;
+}
+
+d3_Lab.prototype.chroma = function(c) {
+ var a = this.a,
+ b = this.b;
+
+ if (arguments.length) {
+ return d3_lab_lch(this.l, c, this.hue())
+ }
+
+ return Math.sqrt(a * a + b * b);
+}
+
+d3_Lab.prototype.toString = function() {
+ return this.rgb().toString();
+};
+
+function d3_lab_rgb(l, a, b) {
+ var y = (l + 16) / 116,
+ x = a / 500 + y,
+ z = y - b / 200;
+
+ function v(x) {
+ var p = x * x * x;
+ return p > 0.008856 ? p : (x - 16 / 116) / 7.787;
+ }
+
+ var x = v(x) * 0.95047,
+ y = v(y),
+ z = v(z) * 1.08883;
+
+ var r = x * 3.2406 + y * -1.5372 + z * -0.4986,
+ g = x * -0.9689 + y * 1.8758 + z * 0.0415,
+ b = x * 0.0557 + y * -0.2040 + z * 1.0570;
+
+ function w(r) {
+ r = r > 0.0031308 ? 1.055 * Math.pow(r, (1 / 2.4)) - 0.055 : 12.92 * r;
+ return Math.round(r * 255)
+ }
+
+ return d3_rgb(w(r), w(g), w(b));
+}
+
+function d3_lab_lch(l, c, h) {
+ var hr = h * (Math.PI / 180),
+ a = Math.cos(hr) * c,
+ b = Math.sin(hr) * c;
+ return d3_lab(l, a, b);
+}
+
View
40 src/core/rgb.js
@@ -43,6 +43,10 @@ d3_Rgb.prototype.hsl = function() {
return d3_rgb_hsl(this.r, this.g, this.b);
};
+d3_Rgb.prototype.lab = function() {
+ return d3_rgb_lab(this.r, this.g, this.b);
+};
+
d3_Rgb.prototype.toString = function() {
return "#" + d3_rgb_hex(this.r) + d3_rgb_hex(this.g) + d3_rgb_hex(this.b);
};
@@ -124,6 +128,42 @@ function d3_rgb_hsl(r, g, b) {
return d3_hsl(h, s, l);
}
+function d3_rgb_lab(r, g, b) {
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+ function v(r) {
+ return r > 0.04045 ? Math.pow(((r + 0.055) / 1.055), 2.4) : r / 12.92;
+ }
+
+ r = v(r) * 100;
+ g = v(g) * 100;
+ b = v(b) * 100;
+
+ var x = r * 0.4124 + g * 0.3576 + b * 0.1805,
+ y = r * 0.2126 + g * 0.7152 + b * 0.0722,
+ z = r * 0.0193 + g * 0.1192 + b * 0.9505;
+
+ x /= 95.047;
+ y /= 100.000;
+ z /= 108.883;
+
+ function w(x) {
+ return x > 0.008856 ? Math.pow(x, 1 / 3) : (7.787 * x) + (16 / 116);
+ }
+
+ x = w(x);
+ y = w(y);
+ z = w(z);
+
+ var l = y > 0.008856 ? (116 * y) - 16 : 903.3 * y,
+ a = 500 * (x - y),
+ b = 200 * (y - z);
+
+ return d3_lab(l, a, b);
+}
+
function d3_rgb_parseNumber(c) { // either integer or percentage
var f = parseFloat(c);
return c.charAt(c.length - 1) === "%" ? Math.round(f * 2.55) : f;
View
64 test/core/lab-test.js
@@ -0,0 +1,64 @@
+require("../env");
+
+var vows = require("vows"),
+ assert = require("assert");
+
+var suite = vows.describe("d3.lab");
+
+suite.addBatch({
+ "lab": {
+ topic: function() {
+ return d3.lab;
+ },
+ "exposes l, a, and b properties": function(lab) {
+ var color = lab("#048F07");
+ assert.equal(color.l, 51.480406534539185);
+ assert.equal(color.a, -55.524431964471155);
+ assert.equal(color.b, 52.88478430006963);
+ },
+ "can convert from CieLAB": function(lab) {
+ assert.labEqual(lab(d3.lab(40, 50, 60)), 40, 50, 60);
+ },
+ "can convert from RGB": function(lab) {
+ assert.labEqual(d3.rgb("#048F07").lab(), 51.480406534539185, -55.524431964471155, 52.88478430006963);
+ },
+ "can initialize with RGB": function(lab) {
+ assert.labEqual(lab(d3.rgb("#048F07")), 51.480406534539185, -55.524431964471155, 52.88478430006963);
+ },
+ "can convert to RGB": function(lab) {
+ assert.rgbEqual(lab(d3.rgb(30, 40, 50)).rgb(), 30, 40, 50);
+ },
+ "can derive a brighter color": function(lab) {
+ var brown = lab("brown");
+ assert.equal(brown.brighter().l, brown.l + 18);
+ assert.equal(brown.brighter(2).l, brown.l + 36);
+ },
+ "can derive a darker color": function(lab) {
+ var brown = lab("brown");
+ assert.equal(brown.darker().l, brown.l - 18);
+ assert.equal(brown.darker(2).l, brown.l - 36);
+ },
+ "can get chroma value": function(lab) {
+ assert.equal(lab("#048F07").chroma(), 76.67961238453204);
+ },
+ "can get hue value": function(lab) {
+ assert.equal(lab("#048F07").hue(), 136.39481492184387);
+ },
+ "can set chroma value": function(lab) {
+ assert.equal(lab("red").chroma(14).chroma(), 14);
+ },
+ "can set hue value": function(lab) {
+ assert.equal(lab("red").hue(14).hue(), 14);
+ },
+ "string coercion returns hexadecimal format": function(lab) {
+ assert.strictEqual(lab("#abcdef") + "", "#abcdef");
+ assert.strictEqual(lab("moccasin") + "", "#ffe4b5");
+ assert.strictEqual(lab("hsl(60, 100%, 20%)") + "", "#666600");
+ assert.strictEqual(lab("rgb(12, 34, 56)") + "", "#0c2238");
+ assert.strictEqual(lab(d3.rgb(12, 34, 56)) + "", "#0c2238");
+ assert.strictEqual(lab(d3.hsl(60, 1, .2)) + "", "#666600");
+ }
+ }
+});
+
+suite.export(module);
View
6 test/env-assert.js
@@ -30,6 +30,12 @@ assert.hslEqual = function(actual, h, s, l, message) {
}
};
+assert.labEqual = function(actual, l, a, b, message) {
+ if (Math.abs(actual.l - l) > 1e-6 || Math.abs(actual.a - a) > 1e-6 || Math.abs(actual.b - b) > 1e-6) {
+ assert.fail("lab(" + actual.l + "," + actual.a + "," + actual.b + ")", "lab(" + l + "," + a + "," + b + ")", message || "expected {expected}, got {actual}", null, assert.labEqual);
+ }
+};
+
assert.pathEqual = function(actual, expected, message) {
if (!pathEqual(actual, expected)) {
assert.fail(formatPath(actual), formatPath(expected), message || "expected {expected}, got {actual}", null, assert.pathEqual);
Something went wrong with that request. Please try again.