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

HWB colour space + CSS Level 4 syntax #71

Open
wants to merge 3 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,20 @@ var steelblue = d3.rgb("steelblue");

<a name="color" href="#color">#</a> d3.<b>color</b>(<i>specifier</i>) [<>](https://github.com/d3/d3-color/blob/master/src/color.js "Source")

Parses the specified [CSS Color Module Level 3](http://www.w3.org/TR/css3-color/#colorunits) *specifier* string, returning an [RGB](#rgb) or [HSL](#hsl) color, along with [CSS Color Module Level 4 hex](https://www.w3.org/TR/css-color-4/#hex-notation) *specifier* strings. If the specifier was not valid, null is returned. Some examples:
Parses the specified [CSS Color Module Level 3](http://www.w3.org/TR/css3-color/#colorunits) or [Level 4](https://www.w3.org/TR/css-color-4) *specifier* string, returning an [RGB](#rgb), [HSL](#hsl) or [HWB](#hwb) color. If the specifier was not valid, null is returned. Some examples:

* `rgb(255, 255, 255)`
* `rgb(10%, 20%, 30%)`
* `rgb(127.5 127.5 127.5)`
* `rgba(255, 255, 255, 0.4)`
* `rgba(10%, 20%, 30%, 0.4)`
* `rgba(127.5 127.5 127.5/40%)`
* `hsl(120, 50%, 20%)`
* `hsla(120, 50%, 20%, 0.4)`
* `hsl(120grad 50% 20%/0.4)`
* `hwb(120 50% 20%)`
* `hwb(120 50% 20%/0.4)`
* `hwb(1rad 30% 20%/40%)`
* `#ffeeaa`
* `#fea`
* `#ffeeaa22`
Expand Down Expand Up @@ -118,6 +124,10 @@ Returns a hexadecimal string representing this color in RGB space, such as `#f7e

Returns a string representing this color according to the [CSS Color Module Level 3 specification](https://www.w3.org/TR/css-color-3/#hsl-color), such as `hsl(257, 50%, 80%)` or `hsla(257, 50%, 80%, 0.2)`. If this color is not displayable, a suitable displayable color is returned instead by clamping S and L channel values to the interval [0, 100].

<a name="color_formatHwb" href="#color_formatHwb">#</a> *color*.<b>formatHwb</b>() [<>](https://github.com/d3/d3-color/blob/master/src/color.js "Source")

Returns a string representing this color according to the [CSS Color Module Level 4 specification](https://www.w3.org/TR/css-color-4/#the-hwb-notation), such as `hwb(257 50% 30%)` or `hwb(257 50% 30%/0.2)`. If this color is not displayable, a suitable displayable color is returned instead by clamping W and B channel values to the interval [0, 100].

<a name="color_formatRgb" href="#color_formatRgb">#</a> *color*.<b>formatRgb</b>() [<>](https://github.com/d3/d3-color/blob/master/src/color.js "Source")

Returns a string representing this color according to the [CSS Object Model specification](https://drafts.csswg.org/cssom/#serialize-a-css-component-value), such as `rgb(247, 234, 186)` or `rgba(247, 234, 186, 0.2)`. If this color is not displayable, a suitable displayable color is returned instead by clamping RGB channel values to the interval [0, 255].
Expand All @@ -142,6 +152,14 @@ Constructs a new [HSL](https://en.wikipedia.org/wiki/HSL_and_HSV) color. The cha

If *h*, *s* and *l* are specified, these represent the channel values of the returned color; an *opacity* may also be specified. If a CSS Color Module Level 3 *specifier* string is specified, it is parsed and then converted to the HSL color space. See [color](#color) for examples. If a [*color*](#color) instance is specified, it is converted to the RGB color space using [*color*.rgb](#color_rgb) and then converted to HSL. (Colors already in the HSL color space skip the conversion to RGB.)

<a name="hwb" href="#hwb">#</a> d3.<b>hwb</b>(<i>h</i>, <i>w</i>, <i>b</i>[, <i>opacity</i>]) [<>](https://github.com/d3/d3-color/blob/master/src/color.js "Source")<br>
<a href="#hwb">#</a> d3.<b>hwb</b>(<i>specifier</i>)<br>
<a href="#hwb">#</a> d3.<b>hwb</b>(<i>color</i>)<br>

Constructs a new [HWB](https://en.wikipedia.org/wiki/HWB_color_model) color. The channel values are exposed as `h`, `w` and `b` properties on the returned instance. Use the [HWB color picker](https://observablehq.com/@parcly-taxel/the-hwb-colour-model) to explore this color space.

If *h*, *w* and *b* are specified, these represent the channel values of the returned color; an *opacity* may also be specified. If a CSS Color Module Level 3 *specifier* string is specified, it is parsed and then converted to the HWB color space. See [color](#color) for examples. If a [*color*](#color) instance is specified, it is converted to the RGB color space using [*color*.rgb](#color_rgb) and then converted to HWB. (Colors already in the HWB color space skip the conversion to RGB.)

<a name="lab" href="#lab">#</a> d3.<b>lab</b>(<i>l</i>, <i>a</i>, <i>b</i>[, <i>opacity</i>]) [<>](https://github.com/d3/d3-color/blob/master/src/lab.js "Source")<br>
<a href="#lab">#</a> d3.<b>lab</b>(<i>specifier</i>)<br>
<a href="#lab">#</a> d3.<b>lab</b>(<i>color</i>)<br>
Expand Down
161 changes: 146 additions & 15 deletions src/color.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import define, {extend} from "./define.js";
import {rad2deg} from "./math.js";

export function Color() {}

export var darker = 0.7;
export var brighter = 1 / darker;

var reI = "\\s*([+-]?\\d+)\\s*",
reN = "\\s*([+-]?\\d*\\.?\\d+(?:[eE][+-]?\\d+)?)\\s*",
reP = "\\s*([+-]?\\d*\\.?\\d+(?:[eE][+-]?\\d+)?)%\\s*",
var reF = "([+-]?\\d*\\.?\\d+(?:[eE][+-]?\\d+)?)",
reN = "\\s*" + reF + "\\s*",
reP = "\\s*" + reF + "%\\s*",
reH = "\\s*" + reF + "((?:deg|grad|rad|turn)?)\\s*",
reA = "\\s*" + reF + "(%?)\\s*",
reHex = /^#([0-9a-f]{3,8})$/,
reRgbInteger = new RegExp("^rgb\\(" + [reI, reI, reI] + "\\)$"),
reRgbPercent = new RegExp("^rgb\\(" + [reP, reP, reP] + "\\)$"),
reRgbaInteger = new RegExp("^rgba\\(" + [reI, reI, reI, reN] + "\\)$"),
reRgbaPercent = new RegExp("^rgba\\(" + [reP, reP, reP, reN] + "\\)$"),
reHslPercent = new RegExp("^hsl\\(" + [reN, reP, reP] + "\\)$"),
reHslaPercent = new RegExp("^hsla\\(" + [reN, reP, reP, reN] + "\\)$");
reRgb = new RegExp("^rgba?\\(" + reN + reN + reN + "\\)$"),
reRgba = new RegExp("^rgba?\\(" + reN + reN + reN + "/" + reA + "\\)$"),
reRgbP = new RegExp("^rgba?\\(" + reP + reP + reP + "\\)$"),
reRgbaP = new RegExp("^rgba?\\(" + reP + reP + reP + "/" + reA + "\\)$"),
reRgbC = new RegExp("^rgba?\\(" + [reN, reN, reN] + "\\)$"),
reRgbaC = new RegExp("^rgba?\\(" + [reN, reN, reN, reA] + "\\)$"),
reRgbPC = new RegExp("^rgba?\\(" + [reP, reP, reP] + "\\)$"),
reRgbaPC = new RegExp("^rgba?\\(" + [reP, reP, reP, reA] + "\\)$"),
reHsl = new RegExp("^hsla?\\(" + reH + reP + reP + "\\)$"),
reHsla = new RegExp("^hsla?\\(" + reH + reP + reP + "/" + reA + "\\)$"),
reHslC = new RegExp("^hsla?\\(" + [reH, reP, reP] + "\\)$"),
reHslaC = new RegExp("^hsla?\\(" + [reH, reP, reP, reA] + "\\)$"),
reHwb = new RegExp("^hwb\\(" + reH + reP + reP + "\\)$"),
reHwba = new RegExp("^hwb\\(" + reH + reP + reP + "/" + reA + "\\)$");

var named = {
aliceblue: 0xf0f8ff,
Expand Down Expand Up @@ -177,6 +188,7 @@ define(Color, color, {
hex: color_formatHex, // Deprecated! Use color.formatHex.
formatHex: color_formatHex,
formatHsl: color_formatHsl,
formatHwb: color_formatHwb,
formatRgb: color_formatRgb,
toString: color_formatRgb
});
Expand All @@ -189,10 +201,29 @@ function color_formatHsl() {
return hslConvert(this).formatHsl();
}

function color_formatHwb() {
return hwbConvert(this).formatHwb();
}

function color_formatRgb() {
return this.rgb().formatRgb();
}

function angle2degrees(value, unit) {
return unit === "" ? +value
: unit === "deg" ? +value
: unit === "grad" ? value * 0.9
: unit === "rad" ? value * rad2deg
: unit === "turn" ? value * 360
: +value;
}

function opacity2number(value, unit) {
return unit === "" ? +value
: unit === "%" ? value * 0.01
: +value;
}

export default function color(format) {
var m, l;
format = (format + "").trim().toLowerCase();
Expand All @@ -201,12 +232,14 @@ export default function color(format) {
: l === 8 ? new Rgb(m >> 24 & 0xff, m >> 16 & 0xff, m >> 8 & 0xff, (m & 0xff) / 0xff) // #ff000000
: l === 4 ? new Rgb((m >> 12 & 0xf) | (m >> 8 & 0xf0), (m >> 8 & 0xf) | (m >> 4 & 0xf0), (m >> 4 & 0xf) | (m & 0xf0), (((m & 0xf) << 4) | (m & 0xf)) / 0xff) // #f000
: null) // invalid hex
: (m = reRgbInteger.exec(format)) ? new Rgb(m[1], m[2], m[3], 1) // rgb(255, 0, 0)
: (m = reRgbPercent.exec(format)) ? new Rgb(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, 1) // rgb(100%, 0%, 0%)
: (m = reRgbaInteger.exec(format)) ? rgba(m[1], m[2], m[3], m[4]) // rgba(255, 0, 0, 1)
: (m = reRgbaPercent.exec(format)) ? rgba(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, m[4]) // rgb(100%, 0%, 0%, 1)
: (m = reHslPercent.exec(format)) ? hsla(m[1], m[2] / 100, m[3] / 100, 1) // hsl(120, 50%, 50%)
: (m = reHslaPercent.exec(format)) ? hsla(m[1], m[2] / 100, m[3] / 100, m[4]) // hsla(120, 50%, 50%, 1)
: (m = reRgb.exec(format) || reRgbC.exec(format)) ? new Rgb(m[1], m[2], m[3], 1) // rgb(255, 0, 0)
: (m = reRgbP.exec(format) || reRgbPC.exec(format)) ? new Rgb(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, 1) // rgb(100%, 0%, 0%)
: (m = reRgba.exec(format) || reRgbaC.exec(format)) ? rgba(m[1], m[2], m[3], opacity2number(m[4], m[5])) // rgba(255, 0, 0, 1)
: (m = reRgbaP.exec(format) || reRgbaPC.exec(format)) ? rgba(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, opacity2number(m[4], m[5])) // rgba(100%, 0%, 0%, 1)
: (m = reHsl.exec(format) || reHslC.exec(format)) ? hsla(angle2degrees(m[1], m[2]), m[3] / 100, m[4] / 100, 1) // hsl(120, 50%, 50%)
: (m = reHsla.exec(format) || reHslaC.exec(format)) ? hsla(angle2degrees(m[1], m[2]), m[3] / 100, m[4] / 100, opacity2number(m[5], m[6])) // hsla(120, 50%, 50%, 1)
: (m = reHwb.exec(format)) ? hwba(angle2degrees(m[1], m[2]), m[3] / 100, m[4] / 100, 1) // hwb(120 0% 0%)
: (m = reHwba.exec(format)) ? hwba(angle2degrees(m[1], m[2]), m[3] / 100, m[4] / 100, opacity2number(m[5], m[6])) // hwba(120 0% 0%/1)
: named.hasOwnProperty(format) ? rgbn(named[format]) // eslint-disable-line no-prototype-builtins
: format === "transparent" ? new Rgb(NaN, NaN, NaN, 0)
: null;
Expand Down Expand Up @@ -281,6 +314,8 @@ function hex(value) {
return (value < 16 ? "0" : "") + value.toString(16);
}

// HSL

function hsla(h, s, l, a) {
if (a <= 0) h = s = l = NaN;
else if (l <= 0 || l >= 1) h = s = NaN;
Expand Down Expand Up @@ -369,3 +404,99 @@ function hsl2rgb(h, m1, m2) {
: h < 240 ? m1 + (m2 - m1) * (240 - h) / 60
: m1) * 255;
}

// HWB

function hwba(h, w, b, a) {
// Treating w, b outside [0,1] as invalid is mandated by CSS4,
// but here we only enforce it when parsing specifiers
if (w < 0 || w > 1 || b < 0 || b > 1) return null;
if (a <= 0) h = w = b = NaN;
else if (w + b >= 1) h = NaN;
return new Hwb(h, w, b, a);
}

export function hwbConvert(o) {
if (o instanceof Hwb) return new Hwb(o.h, o.w, o.b, o.opacity);
if (!(o instanceof Color)) o = color(o);
if (!o) return new Hwb;
if (o instanceof Hwb) return o;
o = o.rgb();
var r = o.r / 255,
g = o.g / 255,
b = o.b / 255,
min = Math.min(r, g, b),
max = Math.max(r, g, b),
h = NaN,
s = max - min;
if (s) {
if (r === max) h = (g - b) / s + (g < b) * 6;
else if (g === max) h = (b - r) / s + 2;
else h = (r - g) / s + 4;
h *= 60;
}
return new Hwb(h, min, 1 - max, o.opacity);
}

export function hwb(h, w, b, opacity) {
return arguments.length === 1 ? hwbConvert(h) : new Hwb(h, w, b, opacity == null ? 1 : opacity);
}

function Hwb(h, w, b, opacity) {
this.h = +h;
this.w = +w;
this.b = +b;
this.opacity = +opacity;
}

define(Hwb, hwb, extend(Color, {
brighter: function(k) {
var wb = shift(this.w, this.b, k == null ? 1 : k);
return new Hwb(this.h, wb[0], wb[1], this.opacity);
},
darker: function(k) {
var wb = shift(this.w, this.b, k == null ? -1 : -k);
return new Hwb(this.h, wb[0], wb[1], this.opacity);
},
rgb: function() {
var h = this.h % 360 + (this.h < 0) * 360,
w = isNaN(this.w) ? 0 : this.w,
g = w + (isNaN(this.b) ? 0 : this.b),
s = Math.min(g, 1);
w /= Math.max(g, 1);
return new Rgb(
hsl2rgb(h >= 240 ? h - 240 : h + 120, 0, 1) * (1 - s) + w * 255,
hsl2rgb(h, 0, 1) * (1 - s) + w * 255,
hsl2rgb(h < 120 ? h + 240 : h - 120, 0, 1) * (1 - s) + w * 255,
this.opacity
);
},
displayable: function() {
return (0 <= this.w && this.w <= 1 || isNaN(this.w))
&& (0 <= this.b && this.b <= 1 || isNaN(this.b))
&& (0 <= this.opacity && this.opacity <= 1);
},
formatHwb: function() {
var a = this.opacity,
w = this.w,
b = this.b;
a = isNaN(a) ? 1 : Math.max(0, Math.min(1, a));
w = isNaN(w) ? 0 : Math.max(0, Math.min(1, w));
b = isNaN(b) ? 0 : Math.max(0, Math.min(1, b));
return "hwb("
+ (this.h || 0) + " "
+ w * 100 + "% "
+ b * 100 + "%"
+ (a === 1 ? ")" : "/" + a + ")");
}
}));

function shift(w, b, k) {
var x = w - b,
y = w + b - 1,
x2 = x + k * 0.3,
y2 = Math.abs(x) === 1 ? 0 : (1 - Math.abs(x2)) * y / (1 - Math.abs(x)),
w2 = (y2 + 1 + x2) / 2,
b2 = (y2 + 1 - x2) / 2;
return [w2, b2];
}
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export {default as color, rgb, hsl} from "./color.js";
export {default as color, rgb, hsl, hwb} from "./color.js";
export {default as lab, hcl, lch, gray} from "./lab.js";
export {default as cubehelix} from "./cubehelix.js";
20 changes: 18 additions & 2 deletions test/color-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ tape("color(format) allows exponential format for hue, opacity and percentages",
test.end();
});

tape("color(format) does not allow decimals for integer values", function(test) {
test.equal(color.color("rgb(120.5,30,50)"), null);
tape("color(format) allows decimals for RGB channel values", function(test) {
test.rgbEqual(color.color("rgb(120.5,30,50)"), 120.5, 30, 50, 1);
test.end();
});

Expand Down Expand Up @@ -167,6 +167,22 @@ tape("color(format) is case-insensitive", function(test) {
test.end();
});

tape("color(format) supports commaless syntax", function(test) {
test.rgbEqual(color.color("rgb(120 30 50)"), 120, 30, 50, 1);
test.rgbEqual(color.color("rgb(120 30 50/0.6)"), 120, 30, 50, 0.6);
test.hslEqual(color.color("hsl(120 30% 50%)"), 120, 0.3, 0.5, 1);
test.hslEqual(color.color("hsl(120 30% 50%/0.6)"), 120, 0.3, 0.5, 0.6);
test.end();
});

tape("color(format) supports units for opacity and hue", function(test) {
test.rgbEqual(color.color("rgba(120 30 50/50%)"), 120, 30, 50, 0.5);
test.hslEqual(color.color("hsla(100grad 30% 50%)"), 90, 0.3, 0.5, 1);
test.hslEqual(color.color("hsl(1rad 30% 50%)"), 57.29577951308232, 0.3, 0.5, 1);
test.hslEqual(color.color("hsl(0.1turn 30% 50%/80%)"), 36, 0.3, 0.5, 0.8);
test.end();
});

tape("color(format) returns undefined RGB channel values for unknown formats", function(test) {
test.equal(color.color("invalid"), null);
test.equal(color.color("hasOwnProperty"), null);
Expand Down
2 changes: 1 addition & 1 deletion test/hsl-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ tape("hsl.toString() reflects h, s and l channel values and opacity", function(t
test.end();
});

tape("hsl.toString() treats undefined channel values as 0", function(test) {
tape("hsl.toString() treats undefined hue as gray, undefined saturation/lightness as 0", function(test) {
test.equal(color.hsl("invalid") + "", "rgb(0, 0, 0)");
test.equal(color.hsl("#000") + "", "rgb(0, 0, 0)");
test.equal(color.hsl("#ccc") + "", "rgb(204, 204, 204)");
Expand Down