Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save aduh95/65b9400953f7d5f1cc4903897a2f0496 to your computer and use it in GitHub Desktop.
Save aduh95/65b9400953f7d5f1cc4903897a2f0496 to your computer and use it in GitHub Desktop.

[Go to memo](./Generate random colors that look great on white background.md).

[
{
"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>

How to generate colors that look great on white background

Naive approach

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).

Hue angle

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.

Contrast ratio

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);
}

Plot the limits

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();

Canvas screenshot for 360x100x100

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!

Worker approach

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:

Canvas screenshot 360x1000x1000

It is way easier to spot a pattern with more data :)

Find curves derivatives

Now I'm gonna try to find the derivatives of the curves, I am expecting to find straight lines, or at least straight enough...

Canvas screenshot

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.

Using the DOM to translate the colors

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.

Luminosity limit using precise HSL to RGB conversion

Now let's have a look to the derivatives:

Canvas screenshot plotting 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º.)

Same derivatives, but on separate graph

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.

Find an equation for a given parabola

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.

Luminosity limit for hue=0º, with the associated parabola equation

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.

Absolute error

Absolute error

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.

Luminosity limit for hue=230º, with the associated parabola equation

We can see that, indeed, the parabola doesn't fit perfectly the data, but that is certainly still negligible for our purpose.

The first coefficient

Let's look at a, as in a*x^2+b*x+c.

a coefficient in function of hue angle

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.

a in function of hue, black stroke represents the computed data, white stroke the linear regression

The second coefficient

Let's look at b, as in a*x^2+b*x+c.

b coefficient in function of hue angle

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

b in function of hue, black stroke represents the computed data, white stroke the linear regression

The third coefficient

Let's look at c, as in a*x^2+b*x+c.

Canvas screenshot

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

The function equation

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;
}

Verify the results

Let's plot the actual ratio this algorithm gives us:

Contrast ratios of the luminosity limit computed at all hue angle

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.

Tweaking the coefficient

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:

Contrast ratios of the luminosity limit computed at all hue angle

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.

Conclusion

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.

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment