[Go to memo](./Generate random colors that look great on white background.md).
-
-
Save aduh95/65b9400953f7d5f1cc4903897a2f0496 to your computer and use it in GitHub Desktop.
data/ |
[ | |
{ | |
"min": 0, | |
"max": 30, | |
"aGradient": 0.007622554, | |
"aIntercept": -0.223522909, | |
"bGradient": -0.006972816, | |
"bIntercept": 0.159400333 | |
}, | |
{ | |
"min": 30, | |
"max": 60, | |
"aGradient": 0.003800316, | |
"aIntercept": -0.125796416, | |
"bGradient": -0.002237613, | |
"bIntercept": 0.044251669 | |
}, | |
{ | |
"min": 60, | |
"max": 90, | |
"aGradient": -0.000978743, | |
"aIntercept": 0.157473997, | |
"bGradient": 0.000476455, | |
"bIntercept": -0.115588066 | |
}, | |
{ | |
"min": 90, | |
"max": 120, | |
"aGradient": -0.000896412, | |
"aIntercept": 0.149120725, | |
"bGradient": 0.000316902, | |
"bIntercept": -0.100832758 | |
}, | |
{ | |
"min": 120, | |
"max": 150, | |
"aGradient": 0.000304561, | |
"aIntercept": 0.005848966, | |
"bGradient": -0.000110541, | |
"bIntercept": -0.049997044 | |
}, | |
{ | |
"min": 150, | |
"max": 180, | |
"aGradient": 0.000375923, | |
"aIntercept": -0.004854929, | |
"bGradient": -0.000138959, | |
"bIntercept": -0.029084065 | |
}, | |
{ | |
"min": 180, | |
"max": 210, | |
"aGradient": -0.003984596, | |
"aIntercept": 0.778812054, | |
"bGradient": 0.002196531, | |
"bIntercept": -0.452163624 | |
}, | |
{ | |
"min": 210, | |
"max": 240, | |
"aGradient": -0.005585318, | |
"aIntercept": 1.120966131, | |
"bGradient": 0.006121434, | |
"bIntercept": -1.240756882 | |
}, | |
{ | |
"min": 240, | |
"max": 270, | |
"aGradient": -0.000135415, | |
"aIntercept": -0.17221474, | |
"bGradient": 0, | |
"bIntercept": 0.21 | |
}, | |
{ | |
"min": 270, | |
"max": 300, | |
"aGradient": 0.001179809, | |
"aIntercept": -0.542348823, | |
"bGradient": 0, | |
"bIntercept": 0.21 | |
}, | |
{ | |
"min": 300, | |
"max": 360, | |
"aGradient": -0.001044457, | |
"aIntercept": 0.133977501, | |
"bGradient": 0.001918519, | |
"bIntercept": -0.43519087 | |
} | |
] |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>Document</title> | |
</head> | |
<body> | |
<script> | |
const MINIMAL_CONTRAST_RATIO = 7; | |
const NB_OF_HUE_TO_TEST = 360; | |
const NB_OF_SATURATION_LEVELS_TO_TEST = 400; | |
const NB_OF_LUMINOSITY_LEVELS_TO_TEST = 400; | |
function hsl2rgb(h, s, l) { | |
const { style } = document.createElement("b"); | |
style.color = `hsl(${h}turn,${s * 100}%,${l * 100}%)`; | |
const [_, r, g, b] = style | |
.getPropertyValue("color") | |
.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); | |
return [r, g, b].map(Number); | |
} | |
const RGB2Luminescence = (RsRGB, GsRGB, BsRGB) => { | |
let R, G, B; | |
if (RsRGB <= 0.03928) R = RsRGB / 12.92; | |
else R = ((RsRGB + 0.055) / 1.055) ** 2.4; | |
if (GsRGB <= 0.03928) G = GsRGB / 12.92; | |
else G = ((GsRGB + 0.055) / 1.055) ** 2.4; | |
if (BsRGB <= 0.03928) B = BsRGB / 12.92; | |
else B = ((BsRGB + 0.055) / 1.055) ** 2.4; | |
return 0.2126 * R + 0.7152 * G + 0.0722 * B; | |
}; | |
function computeContrastRatio(color) { | |
const whiteLuminescence = 1.05; | |
return whiteLuminescence / (RGB2Luminescence(...color) + 0.05); | |
} | |
const pause = () => new Promise(resolve => setTimeout(resolve, 20)); | |
async function generateDatabase() { | |
const a = document.createElement("a"); | |
a.addEventListener("click", () => { | |
requestAnimationFrame(() => URL.revokeObjectURL(a.href)); | |
}); | |
for (let hue = 0; hue < NB_OF_HUE_TO_TEST; hue++) { | |
const parts = []; | |
for ( | |
let satIndex = 1; | |
satIndex < NB_OF_SATURATION_LEVELS_TO_TEST; | |
satIndex++ | |
) { | |
const saturation = satIndex / NB_OF_SATURATION_LEVELS_TO_TEST; | |
for ( | |
let lumIndex = 1; | |
lumIndex < NB_OF_LUMINOSITY_LEVELS_TO_TEST; | |
lumIndex++ | |
) { | |
const luminosity = lumIndex / NB_OF_LUMINOSITY_LEVELS_TO_TEST; | |
document.body.style.color = `hsl(${hue / | |
NB_OF_HUE_TO_TEST}turn,${saturation}%,${luminosity}%)`; | |
const [r, g, b] = hsl2rgb( | |
hue / NB_OF_HUE_TO_TEST, | |
saturation, | |
luminosity | |
); | |
if ( | |
computeContrastRatio([r, g, b].map(n => n / 255)) > | |
MINIMAL_CONTRAST_RATIO | |
) | |
parts.push({ | |
hue, | |
saturation, | |
luminosity, | |
r, | |
g, | |
b, | |
}); | |
} | |
} | |
a.download = hue + ".json"; | |
a.href = URL.createObjectURL( | |
new Blob(["[" + parts.map(JSON.stringify).join(",") + "]"], { | |
type: "text/plain", | |
}) | |
); | |
a.click(); | |
await pause(); | |
} | |
} | |
generateDatabase() | |
.then(() => document.body.append("Done!")) | |
.catch(console.error); | |
</script> | |
</body> | |
</html> |
We could generate a color by generating from a random 16 bit number:
function generateVeryRandomColor() {
const color = (Math.random() * 0xffffff) | 0;
return (
"#" +
"0".repeat(Math.ceil(6 - Math.log2(color) / 4) - 1) +
color.toString(16)
);
}
Although that works fine, it is actually too random for most use cases. In my case, I want the generated color to look nice one next to the other, and to be able to write words on it using a fixed color (E.G.: white).
We can start by a random hue angle, and then add the golden ratio to it to get the next hue angle:
function* generateHue(hueAngle = Math.random()) {
const goldenRatio = (1 + Math.sqrt(5)) / 2;
while (true) yield (hueAngle += goldenRatio);
}
The golden ratio is an efficient way to generate color that are spread evenly across the hue circle.
I was trying to find colors that would look good as background for white text. I wanted to make sure the contrast ratio would meet the WCAG Enhanced Contrast rules.
So I transcribed [their algorithm(https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio)] in JavaScript:
const hsl2rgb = (h, s, l) => {
if (s === 0) {
return [l, l, l];
} else {
const temp1 = l < 0.5 ? l + l * s : l + s - l * s;
const temp2 = 2 * l - temp1;
return [h + 1 / 3, h, h - 1 / 3]
.map((color) => (color + 1) % 1)
.map((color) => {
if (color * 6 < 1) return temp2 + (temp1 - temp2) * 6 * color;
else if (2 * color < 1) return temp1;
else if (color * 3 < 2)
return temp2 + (temp1 - temp2) * (4 - 6 * color);
else return temp2;
});
}
};
/**
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
const RGB2Luminescence = (RsRGB, GsRGB, BsRGB) => {
let R, G, B;
if (RsRGB <= 0.03928) R = RsRGB / 12.92;
else R = ((RsRGB + 0.055) / 1.055) ** 2.4;
if (GsRGB <= 0.03928) G = GsRGB / 12.92;
else G = ((GsRGB + 0.055) / 1.055) ** 2.4;
if (BsRGB <= 0.03928) B = BsRGB / 12.92;
else B = ((BsRGB + 0.055) / 1.055) ** 2.4;
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
};
/**
* @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
*/
function computeContrastRatio(color) {
const whiteLuminescence = 1.05;
return whiteLuminescence / (RGB2Luminescence(...hsl2rgb(...color)) + 0.05);
}
Then I had to check for each color what was the acceptable luminescence for a given hue angle and saturation. I tried to do a brut force computation on the three variables.
const NB_OF_HUE_TO_TEST = 360;
const NB_OF_SATURATION_LEVELS_TO_TEST = 100;
const NB_OF_LUMINOSITY_LEVELS_TO_TEST = 100;
// Result matrix init
const luminosityLimits = Array.from({ length: NB_OF_HUE_TO_TEST }, () => []);
for (let hue = 0; hue < NB_OF_HUE_TO_TEST; hue++) {
for (
let saturation = 1;
saturation < NB_OF_SATURATION_LEVELS_TO_TEST;
saturation++
) {
for (
let luminosity = 1;
luminosity < NB_OF_LUMINOSITY_LEVELS_TO_TEST;
luminosity++
) {
const ratio = computeContrastRatio([
hue / NB_OF_HUE_TO_TEST,
saturation / NB_OF_HUE_TO_TEST,
luminosity / NB_OF_LUMINOSITY_LEVELS_TO_TEST,
]);
if (
ratio > MINIMAL_CONTRAST_RATIO &&
luminosityLimits[hue][saturation] < luminosity
) {
// We are looking for the highest acceptable luminosity
luminosityLimits[hue][saturation] = luminosity;
}
}
}
}
I used the HTML5 <canvas>
element to visualize the data:
const canvas = document.body.appendChild(document.createElement("canvas"));
canvas.width = NB_OF_SATURATION_LEVELS_TO_TEST;
canvas.height = NB_OF_LUMINOSITY_LEVELS_TO_TEST;
const ctx = canvas.getContext("2d");
function drawAxis() {
ctx.clearRect(
0,
0,
NB_OF_SATURATION_LEVELS_TO_TEST,
NB_OF_LUMINOSITY_LEVELS_TO_TEST
);
ctx.strokeStyle = "black";
ctx.beginPath();
ctx.moveTo(0, NB_OF_LUMINOSITY_LEVELS_TO_TEST);
ctx.lineTo(NB_OF_SATURATION_LEVELS_TO_TEST, NB_OF_LUMINOSITY_LEVELS_TO_TEST);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, NB_OF_LUMINOSITY_LEVELS_TO_TEST);
ctx.lineTo(0, 0);
for (let i = 1; i < 10; i++) {
// Horizontal axis scale
ctx.fillText(
i * 10,
1,
NB_OF_LUMINOSITY_LEVELS_TO_TEST -
(NB_OF_LUMINOSITY_LEVELS_TO_TEST / 10) * i
);
}
for (let i = 1; i < 5; i++) {
// Vertical axis scale
ctx.fillText(
i * 20,
(NB_OF_SATURATION_LEVELS_TO_TEST / 5) * i,
NB_OF_LUMINOSITY_LEVELS_TO_TEST - 1
);
}
}
The canvas will adapt its size to the dataset, so we can be ass precise as we want by just changing the initial constants. Now, let's actually put the data onto the canvas:
// Initial value is the value for saturation equals to 0 (grey)
const INITIAL_VALUE = 35 * (NB_OF_LUMINOSITY_LEVELS_TO_TEST / 100);
let hue = 0;
function drawPrimaryFunction() {
ctx.strokeStyle = "hsl(" + hue * (360 / NB_OF_HUE_TO_TEST) + ",50%,50%)";
ctx.beginPath();
ctx.moveTo(0, NB_OF_LUMINOSITY_LEVELS_TO_TEST - INITIAL_VALUE);
for (
let saturation = 1;
saturation < NB_OF_SATURATION_LEVELS_TO_TEST;
saturation++
) {
const luminosityLimit = luminosityLimits[hue][saturation];
ctx.lineTo(saturation, NB_OF_LUMINOSITY_LEVELS_TO_TEST - luminosityLimit);
}
ctx.stroke();
if (++hue < NB_OF_HUE_TO_TEST) requestAnimationFrame(drawPrimaryFunction);
else {
// Printing the numerical values to the console to understand the data on screen
console.log(JSON.stringify(luminosityLimits));
}
// Because some value overlap, you can uncomment next line to reset the graph every 50 degrees
// if (hue % 50 === 0) drawAxis();
}
To make the script runs, I only have two more calls to make:
requestAnimationFrame(drawPrimaryFunction);
drawAxis();
I am not quite content with the current approach:
- it's kind of very slow to run (NBNB_OF_HUE_TO_TEST * NBNB_OF_LUMINOSITY_LEVELS_TO_TEST * NB_OF_SATURATION_LEVELS_TO_TEST get very big)
- The browser crashes on me if I try to increase the values to much
It's time to get off main thread!
Let's create a worker module containing the ratio computation script, and split the work so every computed hue can get garbage collected as soon as we don't need it anymore.
const worker = new Worker("./worker.js");
worker.addEventListener("message", (ev) => {
const { hue, luminosityLimits } = ev.data;
ctx.strokeStyle = "hsl(" + hue * (360 / NB_OF_HUE_TO_TEST) + ",50%,50%)";
ctx.beginPath();
ctx.moveTo(0, NB_OF_LUMINOSITY_LEVELS_TO_TEST - INITIAL_VALUE);
luminosityLimits.forEach((luminosity, saturation) =>
ctx.lineTo(saturation, NB_OF_LUMINOSITY_LEVELS_TO_TEST - luminosityLimit)
);
ctx.stroke();
});
drawAxis();
for (let hue = 0; hue < NB_OF_HUE_TO_TEST; hue++) {
worker.postMessage(hue);
}
It is way easier for my computer to handle the charge! I am able to produce way more precise graphs:
It is way easier to spot a pattern with more data :)
Now I'm gonna try to find the derivatives of the curves, I am expecting to find straight lines, or at least straight enough...
Well that's ain't it! That clearly look like hyperbola to me, or maybe something even more complicated. My guess is there is a problem with my algorithm to translate HSL to RGB. Let's try to use the DOM instead.
function hsl2rgb(h, s, l) {
const { style } = document.createElement("b");
style.color = `hsl(${h}turn,${s * 100}%,${l * 100}%)`;
const [_, r, g, b] = style
.getPropertyValue("color")
.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
return [r, g, b].map(Number);
}
That's going to be way more intensive for the browser, and it cannot be ported to a worker, but it should give way more accurate results. A limitation of this approach is it gives integer RGB values, which means we are "restricted" for our calculations to 0xffffff colors. But since it's all the colors the browser supports anyway, it should work just fine.
I have modified my script so it generates a JSON file for each hue angle. We'll use the JSON files as database to generate the curves.
// pause is suppose to avoid the crash of the process
const pause = () => new Promise((resolve) => setTimeout(resolve, 20));
async function generateDatabase() {
const a = document.createElement("a");
a.addEventListener("click", () => {
setTimeout(() => URL.revokeObjectURL(a.href));
});
for (let hue = 0; hue < NB_OF_HUE_TO_TEST; hue++) {
for (
let satIndex = 1;
satIndex < NB_OF_SATURATION_LEVELS_TO_TEST;
satIndex++
) {
const saturation = satIndex / NB_OF_SATURATION_LEVELS_TO_TEST;
for (
let lumIndex = 1;
lumIndex < NB_OF_LUMINOSITY_LEVELS_TO_TEST;
lumIndex++
) {
const luminosity = lumIndex / NB_OF_LUMINOSITY_LEVELS_TO_TEST;
const [r, g, b] = hsl2rgb(
hue / NB_OF_HUE_TO_TEST,
saturation,
luminosity
);
if (computeContrastRatio([r, g, b].map((n) => n / 255)) > MINI)
document.body.append(
JSON.stringify({
hue,
saturation,
luminosity,
r,
g,
b,
}),
","
);
}
}
a.download = hue + ".json";
a.href = URL.createObjectURL(
new Blob([parts.map(JSON.stringify).join(",")], {
type: "text/plain",
})
);
a.click();
await pause();
}
}
generateDatabase().catch(console.error);
If you want to run the file on your browser, be aware that it might take a while
and should create 360 files of 3 MB each on your download folder. You should
move those file to a subdirectory data
on the same folder as the HTML file for
the next examples.
Now we have to modify the worker a bit to use the database:
addEventListener("message", ev => {
const hue = ev.data;
fetch("./data/" + hue + ".json")
.then(response =>
response.ok
? response.json()
: Promise.reject(new Error(response.statusText))
)
.then(database => {
const luminosityLimits = [];
for (
let saturation = 1;
saturation < NB_OF_SATURATION_LEVELS_TO_TEST;
saturation++
) {
luminosityLimits[saturation] = Math.max(
...database
.filter(
color =>
color.saturation ===
saturation / NB_OF_SATURATION_LEVELS_TO_TEST &&
color.hue === hue
)
.map(color => color.luminosity * NB_OF_LUMINOSITY_LEVELS_TO_TEST)
);
}
postMessage({ hue, luminosityLimits });
})
.catch(console.error);
And the plot looks like the original one, only a bit more precise.
Now let's have a look to the derivatives:
It's a whole different story there:
- It is very hard to see a pattern because all the colors are at the same spot
- It oscillates between -0.25 and +0.25
Let's try to create a graph for each hue (or let's say every 10º.)
My understanding is that each is a straight line, I won't disregard the second degree factor just yet, but it is clearly very small and may be negligible.
To find the equation of a parabola, the most efficient tool at my disposal involves matrixes. There is no matrix support in native JS (at the time of writing YKMV), so I'm using two JS librairies to help me do the matrix manipulation in an efficient way.
It's a success, let's try to find out if I can find a mathematical relation between a given hue and the parabola equation associated with it.
So what are we looking at here? Mind that the horizontal axis is now showing hue angle (in degrees), I have also added a background so we can visualize what color is associated with which data. The horizontal axis is using some sort of weird scaling, I haven't tried to make sense out of it, because the actual value doesn't actually matter to me.
It is showing the correlation between the parabola equation and the actual data – meaning it represents the confidence of the model on the data (lower is better). For example, in the blue-purple spectrum, the confidence is lower than in the yellow-green one.
I find it fascinating that the error function is continuous, I would have expect to find random data, or at least some noise but in reality, not so much.
My conclusion looking at this graph, for the hue range where the absolute error is below 200 (less than 2 points per saturation gap), the parabola describes quite well the functions I am looking for. For the other, well, let's look closer at their graphs.
We can see that, indeed, the parabola doesn't fit perfectly the data, but that is certainly still negligible for our purpose.
Let's look at a
, as in a*x^2+b*x+c
.
I am going to take a less rigorous tone here: my goal is to simplify the final equation, so I am not going to try to find the most mathematical way of describing the function. I am going to assume it's composed of 6 straight lines:
- [0-60]
a(hue) = 5.33868e-3 * hue - 0.19198633
- [60-120]
a(hue) = -9.64304e-4 * hue + 0.156285941
- [120-180]
a(hue) = 3.41357e-4 * hue + 0.000868722
- [180-240]
a(hue) = -4.097794e-3 * hue + 0.803975725
- [240-300]
a(hue) = 1.17425e-4 * hue - 0.23830259
- [300-360]
a(hue) = -1.044457e-3 * hue + 0.133977501
The approximation should be good enough for what we are trying to achieve.
Let's look at b
, as in a*x^2+b*x+c
.
Here I am seeing 6 straight lines, I can use a bit of linear regression to find their equation.
- [0-60]
b(hue) = -8.494084e-3 * hue + 0.210984323
- [60-120]
b(hue) = 1.229152e-3 * hue - 0.331667868
- [120-180]
b(hue) = -4.41242e-4 * hue - 0.135208875
- [180-240]
b(hue) = 1.1574875e-2 * hue - 2.352826186
- [240-300]
b(hue) = -5.685428e-3 * hue + 1.863864434
- [300-360]
b(hue) = 1.918519e-3 * hue - 0.43519087
Let's look at c
, as in a*x^2+b*x+c
.
Note: The scale of the graph is magnified by 400
That's a constant value, it is consistent with the limit value for gray (what we
called INITIAL_VALUE
on our previous scripts).
- [0-360]
c(hue) = 0.34806606292724607
So we have now an equation (or rather six) to calculate the acceptable luminosity for a given saturation and hue.
To sum up:
l(s) = a(hue) * sˆ2 + b(hue) * s + c
In this equation, l
is the color luminosity, s
is the color saturation and
hue
is in degrees.
function computeLuminosityLimit(hue, saturation) {
const {
aGradient,
aIntercept,
bGradient,
bIntercept,
} = COEFFICIENT_DATABASE.find(({ min, max }) => max >= hue && min <= hue);
const a = aGradient * hue + aIntercept;
const b = bGradient * hue + bIntercept;
const c = 0.34806606292724607;
return a * saturation * saturation + b * saturation + c;
}
Let's plot the actual ratio this algorithm gives us:
This graph shows what values take the contrast ratio for each huw angle. The expected / targeted result is the black line on the middle (contrast ratio of 7:1). Getting contrast ratio above the bar is actually OK (we might be missing a few colors here and there, but still it meet the WCAG requirements). For colors on the other side of the bar, some user may have trouble reading them.
In order to get closer to the expected value, I managed to tweak the coefficients using a try-and-compare manual process. The final result looks like this:
There are still some false positive, but way fewer false negative so I consider it a win. We can see that the greater the absolute error for a given region, the more false positive we get.
Note: The coefficients used in the previous graph can be found in the attached JSON file.
The last step is the actual function that returns the CSS string representing the color:
const hueGenerator = generateHue();
function generateRandomColor() {
const hue = hueGenerator.next().value;
const saturation = Math.random();
const luminosity = Math.random() * computeLuminosityLimit(hue, saturation);
return `hsl(${hue}turn,${saturation * 100}%,${luminosity * 100}%)`;
}
A final touch would be to avoid to generate color with too low saturation or luminosity values.
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)
(Sorry about that, but we can’t show files that are this big right now.)