Fun With Color Gradients and Math

One of the best ways to display mathematical values to non-technical users is with color. Colors are visually striking and add personality to your interface. And let's face it, most people love to hate math. If any average person sees 0.7071067811865476 on their device screen, they may just attempt to strangle a puppy.

However, since this blog is geared towards a slightly more technical audience, I think I can feel comfortable using slightly more mathematical terminology. There are, like, a bajillion different schemes to map numerical values to color. However, we're going to focus on one in particular: mapping the real values from 0 to 1 inclusive to a color gradient via linear interpolation. That is, mapping this:

to this:

"Why 0 to 1?" you ask. Working with the interval between 0 and 1 has quite a few real world applications directly. For example: probability is measured between 0 and 1. Ratios/fractions/percentages for the most part are all simply a point on the real number line between 0 and 1. Even for values not between 0 and 1, it is usually quite easy to normalize values and thus constrain them to that interval. "Why those colors?" you ask. Because...Go Broncos!

Let's make an app called "Hot n' Cold" where users try to physically stand at a spot on the Earth randomly selected by their device. For example, a random number generator in the app might select 46.1935 latitude and -88.6557 longitude which happens to be in the middle of the forest outside of Iron River, Michigan. The user's mission is to go stand on that spot. Hopefully purchasers of this app have a lot of free travel time and money. We are going to indicate to the user how close they are to the coordinates, i.e., how "Hot" or "Cold" they are to the target location with a simple color from our gradient. That is:

But how is this color determined exactly? We are going to generate it from our real number lying on the interval between 0 and 1. Now a perfectly reasonable question would be "How do we map the distance from the device to the target to a value between 0 and 1?" Well...there's math involved. And as the ever-wise Barbara teaches us: "Math class is tough". So I will not be covering that algorithm here. We'll just be assuming you've obtained an accurate value between 0 and 1 that represents how close the user is to the target with 0 representing the other side of the Earth and 1 representing "standing on top of the spot."

We're going to be using Groovy as our language of choice today. If you're not familiar with Groovy, it is a dynamic JVM language and thus has access to all of the Java platform's creamy library nougat without its high cholesterol, trans-fat verbosity. This lack of mental artery clogging wordiness will hopefully also make Groovy readable to those who aren't fluent in it (or fluent in Java).

To start, let's define our colors:

def start = [0x07, 0x1a, 0x71] // Broncos Blue
def end = [0xff, 0xa5, 0x40] // Broncos Orange

Both start and end are 3 element arrays with the respective red, green, and blue integer components. Next we define an interpolation function. This is probably the meatiest part of the code mathematically speaking. As we have only defined two colors we'll be using linear interpolation.

def interpolationFunction = { startParam, endParam, x -> (int)((1 - x)*startParam + x*endParam).round() }

Ok that's probably an eyefull. Let's talk about this a bit. This line is a Groovy closure. It may be helpful to think of it as an anonymous function that takes 3 parameters: startParam, endParam and x. The x is our all-important value between 0 and 1 that we've been obsessing about from the beginning. It is behaving as a weight on both startParam and endParam. What startParam and endParam are will become more clear in a bit but if you run through the arithmetic in your head, you can see that as x increases, the value of (1 - x)*startParam decreases and thus the influence of startParam on the result of the function wanes. In a similar vein, as x increases, x*endParam increases and the influence of endParam is felt more strongly in the result of the function. If you still look like a deer in the headlights, pick two largish integers for startParam and endParam, say 3 digits long, and play with the function. Assign random values to x between 0 and 1 and convince yourself of the interplay between startParam, endParam, and x.

Let's pretend for a moment that our hot and cold distance value is equal to 0.4 which would map to this color in our spectrum:

We need to transform our start and end colors and our distance value into a form that our interpolation function can make use of:

def distanceValue = 0.4f
def alignedColors = [start, end, [distanceValue]*3].transpose()

The first line is simply hard coding the distance value for demonstration purposes. The second line creates a 3 element array of 3 element arrays. Recall that both of the colors variables is a 3 element array consisting of the RGB integers. The [distanceValue]*3 is Groovy shorthand for [distanceValue, distanceValue, distanceValue]. With a 3x3 array in hand, we call the transpose method. This is the same mathematical operation as transposing a matrix in linear algebra. It is also the same as zipping two arrays in Ruby (and basically the same as zipping in Python). What this transposition does for us is align each of the RGB components in the start and end colors together. Our array of arrays now has all the red components (with a distance value) in the first cell, all the green components (with a distance value) in the second cell, and all the blue components (with a distance value) in the last cell. Each color component can now be individually interpolated with our interpolation function:

def interpolatedColorValues = alignedColors.collect(interpolationFunction)

The collect method calls the interpolationFunction using each of the 3 elements in the alignedColors array. And since each individual alignedColors element has 3 elements, Groovy goes ahead and maps each of those 3 to the corresponding parameter in the closure. A call to the interpolation function returns a single integer. The closure is being called 3 times: one for red, green and blue and thus we get back values for red, green and blue which are stored in the array interpolatedColorValues. And voila! We have everything we need now to generate a color object:

Color interpolatedColor = new Color(interpolatedColorValues[0], interpolatedColorValues[1], interpolatedColorValues[2])

What's peachy about our one line interpolation function is how flexible it is. I'm looking at my color gradient and feeling like the orange is really dominating. That is, the blue fades to that purplish orange color way too soon and then there's not much difference in the upper half of the gradient between the different oranges. We can fix that. I'm going to add a function that remaps the distance value so that the blue to play ball a little longer.

def distanceValue = 0.4f
def mappingFunction = { x -> x*x }
def alignedColors = [start, end, [mappingFunction(distanceValue)]*3].transpose()

The mappingFunction simply squares the distanceValue but it has the effect of delaying the movement from blue to orange as the distanceValue increases:

The possibilities here are endless. The only requirement is that the codomain of your mapping function be constrained to the interval between 0 and 1 just like your distance value.

We could also be really evil to the users of our app and have this be our mapping function:

def mappingFunction = { x -> 0.5 * Math.sin(8*Math.PI*x) + 0.5 }

which produces this gradient:


No Comments