Sorting colors in JavaScript

Sorting colors in JavaScript

How to sort colors in JavaScript? Let me tell you a story first. In the project I'm working on right now we used to have 134 colors in use! WTF?! you say.

Once I discovered that I thought I'm going to show that to my colleagues, and we will address the problem. Unfortunately, I'm a very visual person (so to say) and I couldn't stand the very random order of the colors listed:

This type of personality helps with paying attention to details but is problematic with stuff like this 😅 so...

Let's just sort them

My first approach to fix the problem was to just run regular .sort() on array and hope for the best. This is the result:

Not great, not terrible, right? Unfortunately, we can see some outliers here. Like reds are placed completely random.

Okay, I think I forgot to say that we don't only use HEX values in our codebase but also rgba. That should explain why HEX values like #999 are placed quite far away from rgba(153, 153, 153, 1) even though it's the same grey color and should be placed next to each other.

Keep colors in the same format

There are a couple of formats to hold a color but I know that in my set I have HEX and rgba. So I'm going to convert all hexes into rgba with alpha=1 and pad r,g,b with zeros. This way, simple .sort() will still do the job. Because by default it sorts stuff alphabetically. 004,255,255 should be listed before 086,001,001 and after 001,124,088 and so on.

Let's see the result:

It looks like a step backward. The first 7 colors are basically black but with different alpha channel values. Depending on the background they will look different. Most of them are more grey than black when I look at them.

Wrong approach I think.

Opacity is problematic

So we've just learned that having colors with alpha channel (opacity) is problematic. Depending on the background color they look different:

Color with opacity is affected by background

On the picture above we have the same green circle with opacity on the lefts but depending on the background ( vs ) it's still that color (on the right) or it's a different color now on the left.

To properly sort colors with opacity we will assume the background is white . So we have to apply the background and transform them into new colors. (0, 0, 0, 0.5) which is black with 50% opacity will be changed into (127, 127, 127, 1) .

This technique is called blending and will help us in eliminating the alpha channel. What are the results then?

Not satisfying I'd say. I mean, I see the progress but still, we have outliers here. Some colors started to appear in groups which is good but using simple sort() is naïve I think.

Note
Blending colors is a topic on its own. I'm using a simplified blending technique that may not show exactly the same color as a browser would do. But it's going to be good enough for sorting.

Maybe treat the value as a number?

It seems that simple sort() that sorts strings alphabetically is not the right choice then. So what about treating HEXes as numbers? Let's say every HEX can be represented by a number from 0 (#000000) to 765 (#FFFFFF). Do you see what I did? It's just a sum of HEX parts for r, g, b.

Does it make sense? Look below:

Some parts look better and some worse. Definitely, the darker colors appear on the top and lighter colors appear on the bottom.

But the colors themselves are not grouped. Blues are not together, neither reds nor greens.

Back to square one

So far I had no luck in sorting these colors so I decided to go back to square one and ask myself what I want to accomplish. It seems that I jumped into solutions-mode too quickly. So how do I want to sort these colors?

  • they should be grouped like greens, reds, blues, yellows, etc. together,
  • within these groups, they should be sorted from lighter to darker.

But really? Is the second bullet correct? Should very light blue be listed next to greys like or next to "solid" blues like ? 🤔 What a group even means?

That got me thinking 💡. I was trying to solve a multi-dimensional problem in one dimension. Despite you see my set of colors as a rectangle it is in fact a "linear" array with colors floated next to each other.

RGB itself has 3 dimensions - red, green, blue but I never thought of this that way. Even more obvious with the HSL format where we operate on hue, saturation, and lightness.

So far I was able to sort colors by lightness without even knowing. Happy accident I'd say. The problem in general is highly subjective as you can see. I'm looking for the order that would please my human eye. From a machine point of view, it was all sorted properly in every attempt.

One dimensional sort

You don't have to trust my words but just play with the select below and choose a different sorting method. Each of them sorts colors just by one dimension - hue, saturation, or lightness.

Doesn't look like sorted, does it? At least for me, sorting by hue and saturation is not intuitive. Lightness looks pretty good comparing to them... but that's something we already accomplished and I'm not fully satisfied with the result.

The solution

To properly sort colors we need to add another dimension. If we only sort by hue or saturation or lightness then it's not going to please our human eyes. There is a reason why color palettes are often shown in two-dimensional charts. Three dimensions are even more accurate but we just not got used to them.

I still want to show my colors in a linear matter so I'll introduce the 2nd dimension by forming clusters. Clusters of colors. It's like grouping. Then I'm going to calculate the distance between colors and move the color to the closest cluster.

I have chosen primary + secondary + tertiary colors as the leading colors of each cluster. Additionally, I decided to help to form groups by introducing black, white, and a "grey" color:

Leading colors for each cluster

Measuring distance itself is a mathematical operation on vectors (Wikipedia is your friend). Keeping in mind we have 3 dimensions, we can place a point in 3D space like (x,y,z) = (255,95,87) . Then we just need to calculate if it's closer to orange or green for example.

But there is something important that will affect my sorting. The closer to black, grey, white color a color is the harder it is to spot "the color itself". I mean, the distance to the grey (or black, or white) will be shorter than to something we would call "a color". The easiest way to understand it is by looking at the cube below:

RGB color cube

A huge black-to-grey-to-white monster lives inside.

The result

Enough talking, let's see the result. First look at clusters formed. Then uncheck "Show clusters" to see how it all looks together. I filtered out clusters that don't hold any color:

Am I happy with the result? Nah, not 100%. I still see some outliers. But I call it good enough. Especially when I show it in clusters.

Now I can reveal that this list was our input to start thinking about a palette of colors for our styleguide... and for that reason, this grouping (sorting) worked perfectly.

If time lets me do so I'll revisit this problem and find an even better solution. Remember I mentioned the black-to-grey-to-white monster living in the RGB cube? He holds the answers 🔑.

For example, I see this blue color is grouped under grey . The distance to grey is equal to ~120 units where the closest "blue" (azure ) is ~140 units away. There must be some non-linear connotation there for us humans.

Human perception!

If you were wondering if we are machines, then it looks like we are not. At least the algorithms in our brains are more complex than it's needed. That's not elegant Mr. God!

The monster I was talking about is the way we perceive colors. For many years there were many attempts to handle that. What's most important for us here is that RGB space is non-uniform. This means the Euclidian distance between points does not represent what we, humans, perceive. As we have seen in the calculation example above.

Algorithms that claim to be better at measuring the distance are often represented in CIELAB ΔE* space which was designed to be uniform. This means if you go in any direction then the way we perceive the change is linear. That was proven to be wrong. Different formulas (CIE76, CIE94, CIEDE2000) were proposed through the years to be more accurate and compensate for some non-linear areas in this space.

I'll write about it the other day and update this post. As I said, good enough for now.

The code

First of all, do yourself a favor and use a library to convert colors from all formats to all formats ;) There is no reason to write it by hand. I picked color-util.

Blending

In case you have rgba colors use this function:

1function blendRgbaWithWhite(rgba) {
2 const color = colorUtil.color(rgba);
3 const a = color.rgb.a / 256;
4 const r = Math.floor(color.rgb.r * a + 0xff * (1 - a));
5 const g = Math.floor(color.rgb.g * a + 0xff * (1 - a));
6 const b = Math.floor(color.rgb.b * a + 0xff * (1 - a));
7
8 return '#' + ((r << 16) | (g << 8) | b).toString(16);
9}
10

We parse the rgba, normalize the alpha value (line 3) and then make a mathematical transformation on every channel. We will keep the result as hex.

Preparing clusters

You can add as many clusters (groups) as you want but for me, these colors were just fine:

const clusters = [
{ name: 'red', leadColor: [255, 0, 0], colors: [] },
{ name: 'orange', leadColor: [255, 128, 0], colors: [] },
{ name: 'yellow', leadColor: [255, 255, 0], colors: [] },
{ name: 'chartreuse', leadColor: [128, 255, 0], colors: [] },
{ name: 'green', leadColor: [0, 255, 0], colors: [] },
{ name: 'spring green', leadColor: [0, 255, 128], colors: [] },
{ name: 'cyan', leadColor: [0, 255, 255], colors: [] },
{ name: 'azure', leadColor: [0, 127, 255], colors: [] },
{ name: 'blue', leadColor: [0, 0, 255], colors: [] },
{ name: 'violet', leadColor: [127, 0, 255], colors: [] },
{ name: 'magenta', leadColor: [255, 0, 255], colors: [] },
{ name: 'rose', leadColor: [255, 0, 128], colors: [] },
{ name: 'black', leadColor: [0, 0, 0], colors: [] },
{ name: 'grey', leadColor: [235, 235, 235], colors: [] },
{ name: 'white', leadColor: [255, 255, 255], colors: [] },
];

Each of them has a leading color in RGB-array format and colors array waiting to be filled.

Sorting

The sorting algorithm looks like this:

1function sortWithClusters(colorsToSort) {
2 // const clusters = [...]; // as defined above
3
4 const mappedColors = colorsToSort
5 .map((color) => {
6 const isRgba = color.includes('rgba');
7 if (isRgba) {
8 return blendRgbaWithWhite(color);
9 } else {
10 return color;
11 }
12 })
13 .map(colorUtil.color);
14
15 mappedColors.forEach((color) => {
16 let minDistance;
17 let minDistanceClusterIndex;
18
19 clusters.forEach((cluster, clusterIndex) => {
20 const colorRgbArr = [color.rgb.r, color.rgb.g, color.rgb.b];
21 const distance = colorDistance(colorRgbArr, cluster.leadColor);
22
23 if (typeof minDistance === 'undefined' || minDistance > distance) {
24 minDistance = distance;
25 minDistanceClusterIndex = clusterIndex;
26 }
27 });
28
29 clusters[minDistanceClusterIndex].colors.push(color);
30 });
31
32 clusters.forEach((cluster) => {
33 const dim = ['white', 'grey', 'black'].includes(cluster.name) ? 'l' : 's';
34 cluster.colors = oneDimensionSorting(cluster.colors, dim)
35 });
36
37 return clusters;
38}
39
Available onAvailable on Codepen

What happens here?

  • between lines 4-13 we normalize the input by blending rgba and using a util function to store colors
  • then we iterate through colors and each cluster to find the closest one (lines 21-26),
  • in the end, we push the color to a cluster (line 29)

The distance

In line 21 I call a colorDistance function. The true power and accuracy of sorting are hidden in calculating the distance between colors. But as noted before, we go a simple Euclidian way:

function colorDistance(color1, color2) {
const x =
Math.pow(color1[0] - color2[0], 2) +
Math.pow(color1[1] - color2[1], 2) +
Math.pow(color1[2] - color2[2], 2);
return Math.sqrt(x);
}

For computational reasons we could skip Math.sqrt so sorting would work faster and the result would be exactly the same. For teaching purposes, I'll leave it as it is.

One dimension sorting

To sort colors within groups I have a strategy. Black, grey, and white colors are sorted by lightness and other colors by saturation. For me, it produced the best results. Here is the oneDimensionSorting function:

function oneDimensionSorting(colors, dim) {
return colors
.sort((colorA, colorB) => {
if (colorA.hsl[dim] < colorB.hsl[dim]) {
return -1;
} else if (colorA.hsl[dim] > colorB.hsl[dim]) {
return 1;
} else {
return 0;
}
});
}

Check the full code on CodePen to see how it all plays together.

What's next?

For my purposes, the sorting and grouping worked good but I can imagine you might want to be more accurate. The problem is non-trivial as you see, and this post is already super long. People make PhDs on how to measure the distance between colors and create new spaces that would properly model human perception!

Watch this space. Cheers!

PS. If you like algorithms then check Search with typo tolerance.