iwanthue
Version:
Colors for data scientists. Generate and refine palettes of optimally distinct colors.
477 lines (364 loc) • 11.9 kB
JavaScript
/**
* Iwanthue Library Endpoint
* ==========================
*
* Exporting the main utilities of the library.
*/
var Random = require('./rng.js');
var CachedDistances = require('./distances.js');
var helpers = require('./helpers.js');
var presets = require('./presets.js');
var validateRgb = helpers.validateRgb;
var labToRgb = helpers.labToRgb;
var labToRgbHex = helpers.labToRgbHex;
var labToHcl = helpers.labToHcl;
var diffSort = helpers.diffSort;
/**
* Constants.
*/
var DEFAULT_SETTINGS = {
attempts: 1,
colorFilter: null,
colorSpace: 'default',
clustering: 'k-means',
quality: 50,
ultraPrecision: false,
distance: 'euclidean',
seed: null
};
var VALID_CLUSTERINGS = new Set(['force-vector', 'k-means']);
var VALID_DISTANCES = new Set([
'euclidean',
'cmc',
'compromise',
'protanope',
'deuteranope',
'tritanope'
]);
var VALID_PRESETS = new Set(Object.keys(presets));
/**
* Helpers.
*/
function stringSum(string) {
var sum = 0;
for (var i = 0, l = string.length; i < l; i++)
sum += string.charCodeAt(i);
return sum;
}
function resolveAndValidateSettings(userSettings) {
var settings = Object.assign({}, DEFAULT_SETTINGS, userSettings);
if (typeof settings.attempts !== 'number' || settings.attempts <= 0)
throw new Error('iwanthue: invalid `attempts` setting. Expecting a positive number.');
if (settings.colorFilter && typeof settings.colorFilter !== 'function')
throw new Error('iwanthue: invalid `colorFilter` setting. Expecting a function.');
if (!VALID_CLUSTERINGS.has(settings.clustering))
throw new Error('iwanthue: unknown `clustering` "' + settings.clustering + '".');
if (typeof settings.quality !== 'number' || isNaN(settings.quality) || settings.quality < 1)
throw new Error('iwanthue: invalid `quality`. Expecting a number > 0.');
if (typeof settings.ultraPrecision !== 'boolean')
throw new Error('iwanthue: invalid `ultraPrecision`. Expecting a boolean.');
if (!VALID_DISTANCES.has(settings.distance))
throw new Error('iwanthue: unknown `distance` "' + settings.distance + '".');
if (typeof settings.seed === 'string')
settings.seed = stringSum(settings.seed);
if (settings.seed !== null && typeof settings.seed !== 'number')
throw new Error('iwanthue: invalid `seed`. Expecting an integer or a string.');
// Building color filter from preset?
if (!settings.colorFilter) {
if (
settings.colorSpace &&
settings.colorSpace !== 'all'
) {
var preset;
if (typeof settings.colorSpace === 'string') {
if (!VALID_PRESETS.has(settings.colorSpace))
throw new Error('iwanthue: unknown `colorSpace` "' + settings.colorSpace + '".');
preset = presets[settings.colorSpace];
}
else if (Array.isArray(settings.colorSpace)) {
if (settings.colorSpace.length !== 6)
throw new Error('iwanthue: expecting a `colorSpace` array of length 6 ([hmin, hmax, cmin, cmax, lmin, lmax]).');
preset = settings.colorSpace;
}
else {
preset = [
settings.colorSpace.hmin || 0,
settings.colorSpace.hmax || 360,
settings.colorSpace.cmin || 0,
settings.colorSpace.cmax || 100,
settings.colorSpace.lmin || 0,
settings.colorSpace.lmax || 100
];
}
if (preset[0] < preset[1])
settings.colorFilter = function(rgb, lab) {
var hcl = labToHcl(lab);
return (
hcl[0] >= preset[0] && hcl[0] <= preset[1] &&
hcl[1] >= preset[2] && hcl[1] <= preset[3] &&
hcl[2] >= preset[4] && hcl[2] <= preset[5]
);
};
else
settings.colorFilter = function(rgb, lab) {
var hcl = labToHcl(lab);
return (
(hcl[0] >= preset[0] || hcl[0] <= preset[1]) &&
hcl[1] >= preset[2] && hcl[1] <= preset[3] &&
hcl[2] >= preset[4] && hcl[2] <= preset[5]
);
};
}
}
return settings;
}
// NOTE: this function has complexity O(∞).
function sampleLabColors(rng, count, validColor) {
var colors = new Array(count),
lab,
rgb;
for (var i = 0; i < count; i++) {
do {
lab = [
100 * rng(),
100 * (2 * rng() - 1),
100 * (2 * rng() - 1)
];
rgb = labToRgb(lab);
} while (!validColor(rgb, lab));
colors[i] = lab;
}
return colors;
}
var REPULSION = 100;
var SPEED = 100;
function forceVector(rng, distance, validColor, colors, settings) {
var vectors = new Array(colors.length);
var steps = settings.quality * 20;
var i, j, l = colors.length;
var A, B;
var d, dl, da, db, force, candidateLab, color, ratio, displacement, rgb;
while (steps-- > 0) {
// Initializing vectors
for (i = 0; i < l; i++)
vectors[i] = {dl: 0, da: 0, db: 0};
// Computing force
for (i = 0; i < l; i++) {
A = colors[i];
for (j = 0; j < i; j++) {
B = colors[j];
// Repulsion
d = distance(A, B);
if (d > 0) {
dl = A[0] - B[0];
da = A[1] - B[1];
db = A[2] - B[2];
force = REPULSION / Math.pow(d, 2);
vectors[i].dl += (dl * force) / d;
vectors[i].da += (da * force) / d;
vectors[i].db += (db * force) / d;
vectors[j].dl -= (dl * force) / d;
vectors[j].da -= (da * force) / d;
vectors[j].db -= (db * force) / d;
}
else {
// Jitter
vectors[j].dl += 2 - 4 * rng();
vectors[j].da += 2 - 4 * rng();
vectors[j].db += 2 - 4 * rng();
}
}
}
// Applying force
for (i = 0; i < l; i++) {
color = colors[i];
displacement = SPEED * Math.sqrt(
Math.pow(vectors[i].dl, 2) +
Math.pow(vectors[i].da, 2) +
Math.pow(vectors[i].db, 2)
);
if (displacement > 0) {
ratio = (SPEED * Math.min(0.1, displacement)) / displacement;
candidateLab = [
color[0] + vectors[i].dl * ratio,
color[1] + vectors[i].da * ratio,
color[2] + vectors[i].db * ratio
];
rgb = labToRgb(candidateLab);
if (validColor(rgb, candidateLab))
colors[i] = candidateLab;
}
}
}
}
function kMeans(distance, validColor, colors, settings) {
var colorSamples = [];
var samplesClosest = [];
var l, a, b;
var lab, rgb;
var linc = 5,
ainc = 10,
binc = 10;
if (settings.ultraPrecision) {
linc = 1;
ainc = 5;
binc = 5;
}
for (l = 0; l <= 100; l += linc) {
for (a = -100; a <= 100; a += ainc) {
for (b = -100; b <= 100; b += binc) {
lab = [l, a, b];
rgb = labToRgb(lab);
if (!validColor(rgb, lab))
continue;
colorSamples.push(lab);
samplesClosest.push(null);
}
}
}
// Steps
var steps = settings.quality;
var i, j;
var A, B;
var li = colorSamples.length,
lj = colors.length;
var d, minDistance, freeColorSamples, count, candidate, closest;
while (steps-- > 0) {
// Finding closest color
for (i = 0; i < li; i++) {
B = colorSamples[i];
minDistance = Infinity;
for (j = 0; j < lj; j++) {
A = colors[j];
d = distance(A, B);
if (d < minDistance) {
minDistance = d;
samplesClosest[i] = j;
}
}
}
freeColorSamples = colorSamples.slice();
for (j = 0; j < lj; j++) {
count = 0;
candidate = [0, 0, 0];
for (i = 0; i < li; i++) {
if (samplesClosest[i] === j) {
count++;
candidate[0] += colorSamples[i][0];
candidate[1] += colorSamples[i][1];
candidate[2] += colorSamples[i][2];
}
}
if (count !== 0) {
candidate[0] /= count;
candidate[1] /= count;
candidate[2] /= count;
rgb = labToRgb(candidate);
if (validColor(rgb, candidate)) {
colors[j] = candidate;
}
else {
// The candidate is out of the boundaries of our color space or unfound
if (freeColorSamples.length > 0) {
// We just search for the closest free color
minDistance = Infinity;
closest = -1;
for (i = 0; i < freeColorSamples.length; i++) {
d = distance(freeColorSamples[i], candidate);
if (d < minDistance) {
minDistance = d;
closest = i;
}
}
colors[j] = colorSamples[closest];
}
else {
// Then we just search for the closest color
minDistance = Infinity;
closest = -1;
for (i = 0; i < colorSamples.length; i++) {
d = distance(colorSamples[i], candidate);
if (d < minDistance) {
minDistance = d;
closest = i;
}
}
colors[j] = colorSamples[closest];
}
// Cleaning up free samples
/* eslint-disable */
freeColorSamples = freeColorSamples.filter(function(color) {
return (
color[0] !== colors[j][0] ||
color[1] !== colors[j][1] ||
color[2] !== colors[j][2]
)
});
/* eslint-enable */
}
}
}
}
return colors;
}
/**
* Function generating a iwanthue palette.
*
* @param {number} count - Number of colors in the palette.
* @param {object} settings - Optional settings:
* @param {function} colorFilter - Function filtering unwanted colors.
* @param {string} clustering - Clustering method to use. Either 'force-vector' or 'k-means'.
* @param {number} quality - Quality of the clustering, i.e. number of steps/iterations.
* @param {boolean} ultraPrecision - Whether to use ultra precision or not.
* @param {string} distance - Name of the color distance function to use. Defaults to 'colorblind'.
* @param {number} seed - Seed for random number generation.
* @return {Array} - The computed palette as an array of hexadecimal colors.
*/
module.exports = function generatePalette(count, settings) {
if (typeof count !== 'number' || count < 1)
throw new Error('iwanthue: expecting a color count > 1.');
settings = resolveAndValidateSettings(settings);
var random = new Random(settings.seed);
var rng = function() {
return random.nextFloat();
};
var distances = new CachedDistances();
var distance = distances.get(settings.distance);
var validColor = function(rgb, lab) {
// if (arguments.length < 2)
// throw new Error('validColor takes both rgb and lab!');
if (!validateRgb(rgb))
return false;
if (!settings.colorFilter)
return true;
if (!settings.colorFilter(rgb, lab))
return false;
return true;
};
var colors;
// In this case, we only sample a single color
if (count === 1) {
colors = sampleLabColors(rng, count, validColor);
return [labToRgbHex(colors[0])];
}
var attempts = settings.attempts;
var metrics;
var bestMetric = -Infinity,
best;
while (attempts > 0) {
colors = sampleLabColors(rng, count, validColor);
if (settings.clustering === 'force-vector')
forceVector(rng, distance, validColor, colors, settings);
else
kMeans(distance, validColor, colors, settings);
metrics = helpers.computeQualityMetrics(distance, colors);
if (metrics.min > bestMetric) {
bestMetric = metrics.min;
best = colors;
}
attempts--;
}
colors = best;
colors = diffSort(distance, colors);
return colors.map(labToRgbHex);
};