--- a/browser/components/extensions/ext-theme.js
+++ b/browser/components/extensions/ext-theme.js
@@ -1,12 +1,13 @@
"use strict";
Cu.import("resource://gre/modules/AppConstants.jsm");
Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
// WeakMap[Extension -> Theme]
let themeMap = new WeakMap();
let styleSheetService = Components.classes["@mozilla.org/content/style-sheet-service;1"]
.getService(Components.interfaces.nsIStyleSheetService);
let ioService = Components.classes["@mozilla.org/network/io-service;1"]
.getService(Components.interfaces.nsIIOService);
@@ -417,39 +418,44 @@ class Theme {
this.loadContentScript();
this.baseURI = baseURI;
this.userSheetURI = null;
this.LWTStyles = {};
this.aboutHomeCSSVars = {};
this.browserStyleOverrides = {};
this.cssVars = {};
this.ntp_video = null;
- this.load(manifest.theme);
- this.render();
+ this.load(manifest.theme, () => this.render());
}
- load(theme) {
+ load(theme, callback) {
// Order of sections matters because gradients apply on top of colors.
if (theme.images) {
- this.loadImages(theme.images);
- }
- if (theme.colors) {
- this.loadColors(theme.colors);
- }
- if (theme.tints) {
- this.loadTints(theme.tints);
+ this.loadImages(theme.images, theme, () => continueLoad.call(this));
+ } else {
+ continueLoad.call(this);
}
- if (theme.gradients) {
- this.loadGradients(theme.gradients);
- }
- if (theme.properties) {
- this.loadProperties(theme.properties);
- }
- if (theme.icons) {
- this.loadIcons(theme.icons);
+
+ function continueLoad() {
+ if (theme.colors) {
+ this.loadColors(theme.colors);
+ }
+ if (theme.tints) {
+ this.loadTints(theme.tints);
+ }
+ if (theme.gradients) {
+ this.loadGradients(theme.gradients);
+ }
+ if (theme.properties) {
+ this.loadProperties(theme.properties);
+ }
+ if (theme.icons) {
+ this.loadIcons(theme.icons);
+ }
+ callback();
}
}
loadColors(colors) {
for (let color of Object.getOwnPropertyNames(colors)) {
let val = colors[color];
// Since values are optional, they may be `null`.
if (val === null) {
@@ -498,22 +504,24 @@ class Theme {
continue;
}
if (kChromeThemeGradientsVarMap.has(gradient)) {
addToCSSVars.apply(null, [this.cssVars, val].concat(kChromeThemeGradientsVarMap.get(gradient)));
}
}
}
- loadImages(images) {
+ loadImages(images, fullTheme, callback) {
// Use a temporary element to filter the CSS values that themes can provide.
if (!WindowManager.topWindow) {
return;
}
+ let deferred = false;
+
let temp = WindowManager.topWindow.document.createElement("temp");
for (let image of Object.getOwnPropertyNames(images)) {
let val = images[image];
if (!val) {
continue;
}
val = this.baseURI.resolve(val);
let cssURL = 'url("' + val.replace(/"/g, '\\"') + '")';
@@ -524,25 +532,45 @@ class Theme {
"--page-background-image", ":root");
} else {
temp.style.backgroundImage = cssURL;
addToCSSVars(this.aboutHomeCSSVars, `${temp.style.backgroundImage}`,
"--page-background-image", ":root");
}
} else if (image == "theme_frame" || image == "headerURL") {
this.LWTStyles.headerURL = val;
+ if (!fullTheme.colors || !Object.keys(fullTheme.colors).length) {
+ deferred = true;
+ const CA = Cc["@mozilla.org/places/colorAnalyzer;1"].getService(Ci.mozIColorAnalyzer);
+ CA.findDominantColors(Services.io.newURI(val, "", null), function(success, color, pal) {
+ if (!success) {
+ callback();
+ return;
+ }
+
+ pal = JSON.parse(pal);
+ fullTheme.colors = {
+ frame: pal.mutedColor,
+ tab_text: pal.vibrantColor
+ };
+ callback();
+ });
+ }
} else if (image == "theme_toolbar") {
temp.style.backgroundImage = cssURL;
addToCSSVars(this.cssVars, `${temp.style.backgroundImage}`,
"--toolbar-background-image", ":root");
}
if (kBrowserOverrideImagesVarMap.has(image)) {
addToBrowserOverrides(this.browserStyleOverrides, cssURL, kBrowserOverrideImagesVarMap.get(image));
}
}
+
+ if (!deferred)
+ callback();
}
loadProperties(properties) {
for (let property of Object.getOwnPropertyNames(properties || {})) {
let val = properties[property];
// Since values are optional, they may be `null`.
if (val === null) {
continue;
@@ -798,16 +826,19 @@ extensions.on("shutdown", (type, extensi
});
/* eslint-enable mozilla/balanced-listeners */
extensions.registerSchemaAPI("theme", "addon_parent", context => {
let {extension} = context;
return {
theme: {
update(details) {
- let theme = themeMap.get(extension);
- theme.load(details);
- theme.render({asUpdate: true});
- return Promise.resolve();
+ return new Promise(resolve => {
+ let theme = themeMap.get(extension);
+ theme.load(details, () => {
+ theme.render({asUpdate: true});
+ resolve();
+ });
+ });
},
},
};
});
--- a/toolkit/components/places/ColorAnalyzer.js
+++ b/toolkit/components/places/ColorAnalyzer.js
@@ -23,61 +23,71 @@ function ColorAnalyzer() {
this.worker = new ChromeWorker("resource://gre/modules/ColorAnalyzer_worker.js");
this.worker.onmessage = this.onWorkerMessage.bind(this);
this.worker.onerror = this.onWorkerError.bind(this);
}
ColorAnalyzer.prototype = {
findRepresentativeColor: function ColorAnalyzer_frc(imageURI, callback) {
+ this.loadImage(imageURI, "representative", callback);
+ },
+
+ findDominantColors(imageURI, callback) {
+ this.loadImage(imageURI, "dominant", callback);
+ },
+
+ loadImage(imageURI, operation, callback) {
function cleanup() {
image.removeEventListener("load", loadListener);
image.removeEventListener("error", errorListener);
}
let image = this.hiddenWindowDoc.createElementNS(XHTML_NS, "img");
- let loadListener = this.onImageLoad.bind(this, image, callback, cleanup);
+ let loadListener = this.onImageLoad.bind(this, image, operation, callback, cleanup);
let errorListener = this.onImageError.bind(this, image, callback, cleanup);
image.addEventListener("load", loadListener);
image.addEventListener("error", errorListener);
image.src = imageURI.spec;
},
- onImageLoad: function ColorAnalyzer_onImageLoad(image, callback, cleanup) {
- if (image.naturalWidth * image.naturalHeight > MAXIMUM_PIXELS) {
+ onImageLoad: function ColorAnalyzer_onImageLoad(image, operation, callback, cleanup) {
+ if (operation == "representative" && image.naturalWidth * image.naturalHeight > MAXIMUM_PIXELS) {
// this will probably take too long to process - fail
callback.onComplete(false);
} else {
let canvas = this.hiddenWindowDoc.createElementNS(XHTML_NS, "canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
this.startJob(ctx.getImageData(0, 0, canvas.width, canvas.height),
- callback);
+ operation, callback);
}
cleanup();
},
onImageError: function ColorAnalyzer_onImageError(image, callback, cleanup) {
Cu.reportError("ColorAnalyzer: image at " + image.src + " didn't load");
callback.onComplete(false);
cleanup();
},
- startJob: function ColorAnalyzer_startJob(imageData, callback) {
+ startJob: function ColorAnalyzer_startJob(imageData, operation, callback) {
this.callbacks.push(callback);
- this.worker.postMessage({ imageData: imageData, maxColors: 1 });
+ this.worker.postMessage({ imageData: imageData, operation, maxColors: 1 });
},
onWorkerMessage: function ColorAnalyzer_onWorkerMessage(event) {
// colors can be empty on failure
- if (event.data.colors.length < 1) {
+ let colors = event.data.colors;
+ let isArray = Array.isArray(colors);
+ if (isArray && colors.length < 1) {
this.callbacks.shift().onComplete(false);
} else {
- this.callbacks.shift().onComplete(true, event.data.colors[0]);
+ this.callbacks.shift().onComplete(true, isArray ? colors[0] : 0, JSON.stringify(colors));
}
},
onWorkerError: function ColorAnalyzer_onWorkerError(error) {
// this shouldn't happen, but just in case
error.preventDefault();
Cu.reportError("ColorAnalyzer worker: " + error.message);
this.callbacks.shift().onComplete(false);
--- a/toolkit/components/places/ColorAnalyzer_worker.js
+++ b/toolkit/components/places/ColorAnalyzer_worker.js
@@ -1,15 +1,16 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
-importScripts("ClusterLib.js", "ColorConversion.js");
+importScripts("ClusterLib.js", "ColorConversion.js",
+ "resource://gre/modules/Color.jsm", "Palette.js");
// Offsets in the ImageData pixel array to reach pixel colors
const PIXEL_RED = 0;
const PIXEL_GREEN = 1;
const PIXEL_BLUE = 2;
const PIXEL_ALPHA = 3;
// Number of components in one ImageData pixel (RGBA)
@@ -61,26 +62,28 @@ const CHROMA_WEIGHT_MIDDLE = (CHROMA_WEI
*
* @param event
* A MessageEvent whose data should have the following properties:
* imageData - A DOM ImageData instance to analyze
* maxColors - The maximum number of representative colors to find,
* defaults to 1 if not provided
*/
onmessage = function(event) {
- let imageData = event.data.imageData;
- let pixels = imageData.data;
- let width = imageData.width;
- let height = imageData.height;
- let maxColors = event.data.maxColors;
+ let {imageData, operation, maxColors} = event.data;
+ let {data: pixels, width, height} = imageData;
if (typeof(maxColors) != "number") {
maxColors = 1;
}
let allColors = getColors(pixels, width, height);
+ if (operation == "dominant") {
+ let pal = new Palette(allColors);
+ postMessage({ colors: pal.toJSON() });
+ return;
+ }
// Only merge top colors by frequency for speed.
let mergedColors = mergeColors(allColors.slice(0, MAX_COLORS_TO_MERGE),
width * height, MERGE_THRESHOLD);
let backgroundColor = getBackgroundColor(pixels, width, height);
mergedColors = mergedColors.map(function(cluster) {
@@ -139,39 +142,41 @@ onmessage = function(event) {
* @param height
* Height of the image, in # of pixels.
*
* @return An array of objects with color and freq properties, sorted
* descending by freq
*/
function getColors(pixels, width, height) {
let colorFrequency = {};
+ let intToRgbMap = {};
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
let offset = (x * NUM_COMPONENTS) + (y * NUM_COMPONENTS * width);
if (pixels[offset + PIXEL_ALPHA] < MIN_ALPHA) {
continue;
}
- let color = pixels[offset + PIXEL_RED] << RED_SHIFT
- | pixels[offset + PIXEL_GREEN] << GREEN_SHIFT
- | pixels[offset + PIXEL_BLUE];
+ let rgb = [pixels[offset + PIXEL_RED], pixels[offset + PIXEL_GREEN],
+ pixels[offset + PIXEL_BLUE]];
+ let color = rgb[0] << RED_SHIFT | rgb[1] << GREEN_SHIFT | rgb[2];
+ intToRgbMap[color] = rgb;
if (color in colorFrequency) {
colorFrequency[color]++;
} else {
colorFrequency[color] = 1;
}
}
}
let colors = [];
for (var color in colorFrequency) {
- colors.push({ color: +color, freq: colorFrequency[+color] });
+ colors.push({ color: +color, rgb: intToRgbMap[color], freq: colorFrequency[+color] });
}
colors.sort(descendingFreqSort);
return colors;
}
/**
* Given an array of objects from getColors, the number of pixels in the
* image, and a merge threshold, run the clustering algorithm on the colors
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/Palette.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This class is heavily inpired by the Android Palette class:
+// https://developer.android.com/reference/android/support/v7/graphics/Palette.html.
+// License: Apache License, Version 2.0.
+
+"use strict";
+
+const TARGET_DARK_LUMA = 0.26;
+const MAX_DARK_LUMA = 0.45;
+const MIN_LIGHT_LUMA = 0.55;
+const TARGET_LIGHT_LUMA = 0.74;
+
+const MIN_NORMAL_LUMA = 0.3;
+const TARGET_NORMAL_LUMA = 0.5;
+const MAX_NORMAL_LUMA = 0.7;
+
+const TARGET_MUTED_SATURATION = 0.3;
+const MAX_MUTED_SATURATION = 0.4;
+
+const TARGET_VIBRANT_SATURATION = 1;
+const MIN_VIBRANT_SATURATION = 0.35;
+
+const WEIGHT_SATURATION = 3;
+const WEIGHT_LUMA = 6;
+const WEIGHT_POPULATION = 1;
+
+class Palette {
+ constructor(colors) {
+ this.colors = colors;
+ this.highestPopulation = null;
+ this.vibrantColor = null;
+ this.mutedColor = null;
+ this.darkVibrantColor = null;
+ this.darkMutedColor = null;
+ this.lightVibrantColor = null;
+ this.lightMutedColor = null;
+
+ this.highestPopulation = this.findMaxPopulation();
+ this.fillColors();
+ }
+
+ findMaxPopulation() {
+ // The first item in the array of colors has the max freq. (It's sorted.)
+ return new Color(...this.colors[0].rgb, this.colors[0].freq);
+ }
+
+ fillColors() {
+ this.vibrantColor = this.findColor(TARGET_NORMAL_LUMA, MIN_NORMAL_LUMA,
+ MAX_NORMAL_LUMA, TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1);
+ this.lightVibrantColor = this.findColor(TARGET_LIGHT_LUMA, MIN_LIGHT_LUMA,
+ 1, TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1);
+ this.darkVibrantColor = this.findColor(TARGET_DARK_LUMA, 0, MAX_DARK_LUMA,
+ TARGET_VIBRANT_SATURATION, MIN_VIBRANT_SATURATION, 1);
+ this.mutedColor = this.findColor(TARGET_NORMAL_LUMA, MIN_NORMAL_LUMA,
+ MAX_NORMAL_LUMA, TARGET_MUTED_SATURATION, 0, MAX_MUTED_SATURATION);
+ this.lightMutedColor = this.findColor(TARGET_LIGHT_LUMA, MIN_LIGHT_LUMA, 1,
+ TARGET_MUTED_SATURATION, 0, MAX_MUTED_SATURATION);
+ this.darkMutedColor = this.findColor(TARGET_DARK_LUMA, 0, MAX_DARK_LUMA,
+ TARGET_MUTED_SATURATION, 0, MAX_MUTED_SATURATION);
+
+ // Populate any colors we couldn't find.
+ this.fillEmptyColors();
+ }
+
+ fillEmptyColors() {
+ if (this.vibrantColor === null) {
+ // If we do not have a vibrant color...
+ if (this.darkVibrantColor !== null) {
+ // ...but we do have a dark vibrant, generate the value by modifying the luma
+ let newHsl = [...this.darkVibrantColor.hsl];
+ newHsl[2] = TARGET_NORMAL_LUMA;
+ this.vibrantColor = new Color(...Color.hslToRgb(newHsl));
+ }
+ }
+ if (this.darkVibrantColor === null) {
+ // If we do not have a dark vibrant color...
+ if (this.vibrantColor !== null) {
+ // ...but we do have a vibrant, generate the value by modifying the luma
+ let newHsl = [...this.vibrantColor.hsl];
+ newHsl[2] = TARGET_DARK_LUMA;
+ this.darkVibrantColor = new Color(...Color.hslToRgb(newHsl));
+ }
+ }
+ }
+
+ isColorSelected(color) {
+ return (this.vibrantColor && this.vibrantColor.equals(color)) ||
+ (this.darkVibrantColor && this.darkVibrantColor.equals(color)) ||
+ (this.lightVibrantColor && this.lightVibrantColor.equals(color)) ||
+ (this.mutedColor && this.mutedColor.equals(color)) ||
+ (this.darkMutedColor && this.darkMutedColor.equals(color)) ||
+ (this.lightMutedColor && this.lightMutedColor.equals(color));
+ }
+
+ findColor(targetLuma, minLuma, maxLuma, targetSaturation, minSaturation, maxSaturation) {
+ let max = null;
+ let maxValue = 0;
+ for (let color of this.colors) {
+ color = new Color(...color.rgb, color.freq);
+ let sat = color.s;
+ let luma = color.l;
+ if (sat >= minSaturation && sat <= maxSaturation && luma >= minLuma &&
+ luma <= maxLuma && !this.isColorSelected(color)) {
+ let thisValue = this.createComparisonValue(sat, targetSaturation, luma,
+ targetLuma, color.population);
+ if (max == null || thisValue > maxValue) {
+ max = color;
+ maxValue = thisValue;
+ }
+ }
+ }
+ return max;
+ }
+
+ createComparisonValue(saturation, targetSaturation, luma, targetLuma, population) {
+ return this.weightedMean(
+ this.invertDiff(saturation, targetSaturation), 3,
+ this.invertDiff(luma, targetLuma), 6.5,
+ population / this.highestPopulation, 0.5
+ );
+ }
+
+ weightedMean(...values) {
+ let sum = 0;
+ let sumWeight = 0;
+ for (let i = 0; i < values.length; i += 2) {
+ let value = values[i];
+ let weight = values[i + 1];
+ sum += value * weight;
+ sumWeight += weight;
+ }
+ return sum / sumWeight;
+ }
+
+ invertDiff(value, targetValue) {
+ return 1 - Math.abs(value - targetValue);
+ }
+
+ toJSON() {
+ return {
+ vibrantColor: this.vibrantColor.rgb,
+ darkVibrantColor: this.darkVibrantColor.rgb,
+ lightVibrantColor: this.lightVibrantColor.rgb,
+ mutedColor: this.mutedColor.rgb,
+ darkMutedColor: this.darkMutedColor.rgb,
+ lightMutedColor: this.lightMutedColor.rgb
+ };
+ }
+}
--- a/toolkit/components/places/moz.build
+++ b/toolkit/components/places/moz.build
@@ -61,16 +61,17 @@ if CONFIG['MOZ_PLACES']:
'BookmarkHTMLUtils.jsm',
'BookmarkJSONUtils.jsm',
'Bookmarks.jsm',
'ClusterLib.js',
'ColorAnalyzer_worker.js',
'ColorConversion.js',
'ExtensionSearchHandler.jsm',
'History.jsm',
+ 'Palette.js',
'PlacesBackups.jsm',
'PlacesDBUtils.jsm',
'PlacesRemoteTabsAutocompleteProvider.jsm',
'PlacesSearchAutocompleteProvider.jsm',
'PlacesSyncUtils.jsm',
'PlacesTransactions.jsm',
'PlacesUtils.jsm',
]
--- a/toolkit/components/places/mozIColorAnalyzer.idl
+++ b/toolkit/components/places/mozIColorAnalyzer.idl
@@ -17,20 +17,20 @@ interface mozIRepresentativeColorCallbac
* Analysis can fail if the image is transparent, imageURI doesn't
* resolve to a valid image, or the image is too big.
*
* @param color
* The representative color as an integer in RGB form.
* e.g. 0xFF0102 == rgb(255,1,2)
* If success is false, color is not provided.
*/
- void onComplete(in boolean success, [optional] in unsigned long color);
+ void onComplete(in boolean success, [optional] in unsigned long color, [optional] in jsval colors);
};
-[scriptable, uuid(d056186c-28a0-494e-aacc-9e433772b143)]
+[scriptable, uuid(0ce30b36-3f67-452b-a91f-0f52b04c7720)]
interface mozIColorAnalyzer : nsISupports
{
/**
* Given an image URI, find the most representative color for that image
* based on the frequency of each color. Preference is given to colors that
* are more interesting. Avoids the background color if it can be
* discerned. Ignores sufficiently transparent colors.
*
@@ -44,9 +44,24 @@ interface mozIColorAnalyzer : nsISupport
* that will load when setting the src attribute of a DOM img element
* should work.
* @param callback
* Function to call when the representative color is found or an
* error occurs.
*/
void findRepresentativeColor(in nsIURI imageURI,
in mozIRepresentativeColorCallback callback);
+
+ /**
+ * Given an image URI, find the most dominant colors for that image based on
+ * the frequency of each color.
+ *
+ * @param imageURI
+ * A URI pointing to the image - ideally a data: URI, but any scheme
+ * that will load when setting the src attribute of a DOM img element
+ * should work.
+ * @param callback
+ * Function to call when the representative color is found or an
+ * error occurs.
+ */
+ void findDominantColors(in nsIURI imageURI,
+ in mozIRepresentativeColorCallback callback);
};
--- a/toolkit/modules/Color.jsm
+++ b/toolkit/modules/Color.jsm
@@ -9,24 +9,48 @@ this.EXPORTED_SYMBOLS = ["Color"];
/**
* Color class, which describes a color.
* In the future, this object may be extended to allow for conversions between
* different color formats and notations, support transparency.
*
* @param {Number} r Red color component
* @param {Number} g Green color component
* @param {Number} b Blue color component
+ * @param {Number} population
*/
-function Color(r, g, b) {
+function Color(r, g, b, population) {
this.r = r;
this.g = g;
this.b = b;
+ this.population = population || 0;
}
Color.prototype = {
+ get hsl() {
+ if (!this._hsl)
+ this._hsl = Color.rgbToHsl(this.r, this.g, this.b);
+ return this._hsl;
+ },
+
+ get h() {
+ return this.hsl[0];
+ },
+
+ get s() {
+ return this.hsl[1];
+ },
+
+ get l() {
+ return this.hsl[2];
+ },
+
+ get rgb() {
+ return [this.r, this.g, this.b];
+ },
+
/**
* Formula from W3C's WCAG 2.0 spec's relative luminance, section 1.4.1,
* http://www.w3.org/TR/WCAG20/.
*
* @return {Number} Relative luminance, represented as number between 0 and 1.
*/
get relativeLuminance() {
let colorArr = [this.r, this.b, this.g].map(color => {
@@ -76,10 +100,120 @@ Color.prototype = {
*
* @param {Color} otherColor Color instance to calculate the contrast with
* @return {Boolean}
*/
isContrastRatioAcceptable(otherColor) {
// Note: this is a high enough value to be considered as 'high contrast',
// but was decided upon empirically.
return this.contrastRatio(otherColor) > 3;
+ },
+
+ equals(color) {
+ if (!(color instanceof Color))
+ return false;
+ return this.r == color.r && this.g == color.g && this.b == color.b;
+ },
+
+ toString() {
+ return `rgb( ${this.r}, ${this.g}, ${this.b} ) :: hsl( ${this.h}, ${this.s},` +
+ ` ${this.l} ) :: population: ${this.population}`;
}
};
+
+// Static methods.
+
+function roundTo(number, digits) {
+ const multiplier = Math.pow(10, digits);
+ return Math.round(number * multiplier) / multiplier;
+}
+
+/**
+ * Convert rgb value to hsl
+ *
+ * @param {Number} r
+ * @param {Number} g
+ * @param {Number} b
+ * @return {Array} Array of hsl values.
+ */
+Color.rgbToHsl = function(r, g, b) {
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+ let max = Math.max(r, g, b);
+ let min = Math.min(r, g, b);
+ let h, s;
+ let l = (max + min) / 2;
+
+ if (max == min) {
+ h = s = 0;
+ } else {
+ let d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+ switch (max) {
+ case r:
+ h = (g - b) / d + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = (b - r) / d + 2;
+ break;
+ case b:
+ h = (r - g) / d + 4;
+ break;
+ }
+
+ h /= 6;
+ }
+
+ return [h, s, l];
+};
+
+/**
+ * Helper function for Color.hslToRgb().
+ *
+ * @param {Number} p
+ * @param {Number} q
+ * @param {Number} t
+ * @return {Number}
+ */
+function hueToRgb(p, q, t) {
+ if (t < 0)
+ t += 1;
+ if (t > 1)
+ t -= 1;
+ if (t < 1 / 6)
+ return p + (q - p) * 6 * t;
+ if (t < 1 / 2)
+ return q;
+ if (t < 2 / 3)
+ return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+}
+
+/**
+ * Converts an HSL color value to RGB. Conversion formula
+ * adapted from http://en.wikipedia.org/wiki/HSL_color_space.
+ * Assumes h, s, and l are contained in the set [0, 1] and
+ * returns r, g, and b in the set [0, 255].
+ *
+ * @param {Number} h The hue
+ * @param {Number} s The saturation
+ * @param {Number} l The lightness
+ * @return {Array} The RGB representation
+ */
+Color.hslToRgb = function(h, s, l) {
+ let r, g, b;
+
+ if (s == 0) {
+ r = g = b = l; // achromatic
+ } else {
+ let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ let p = 2 * l - q;
+
+ r = hueToRgb(p, q, h + 1 / 3);
+ g = hueToRgb(p, q, h);
+ b = hueToRgb(p, q, h - 1 / 3);
+ }
+
+ return [r * 255, g * 255, b * 255];
+};