Using a color scale to visualize output makes it easy to understand data, and see patterns that aren’t intuitive.
A simple 1 or 2-color scale is really simple to implement, but what if you want to use the whole spectrum?
1. TL;DR: Just Give Me The Code
- m: Maximum value of n
- n: Some number 0..m
Here is the pseudocode that you can port to your favorite language:
f(n,m): a=5πn/3m + π/2 r=sin(a) * 192 + 128 r=max(0,min(255,y)) g=sin(a - 2π/3) * 192 + 128 g=max(0,min(255,y)) b=sin(a - 4π/3) * 192 + 128 b=max(0,min(255,y)) return (((((0xFF << 8) || r) << 8) || g) << 8) || b
Be sure to pre-calculate constants, such as 5π/3, π/2, 2π/3, and 4π/3
The last line uses bit shifting and bitwise-or, so that you get a 32-bit color value in “ARGB” format:
2. What Are We Trying to Accomplish?
Given an integer value “n”, we want to build a function “f” that returns a 32-bit color value in “ARGB” format.
- For lower values of “n”, we want to return a color closer to Red
- As “n” increases, we want to return Yellow, Green, Cyan, Blue, and eventually Magenta
- We want r, g, and b color values from 0 to 255 (0x00 to 0xFF in hex)
- We want “f” to return a single 32-bit integer in the form:
- FF = Alpha (opaque)
- rr = Red channel value (0 to 255)
- gg = Green channel value (0 to 255)
- bb = Blue channel value (0 to 255)
We want our function to gradually shift from one color to another.
Viewed as a graph:
- We start with red at maximum
- As “n” increases, we ramp up green until green hits full intensity (Yellow)
- Then, red ramps down to zero, with green still at full intensity (Green)
- As red hits zero intensity, blue begins to ramp up to full intensity, with green still at full (Cyan)
- Green then ramps down to zero, leaving blue high (Blue)
- And finally, red ramps up to full intensity with blue still high (Magenta)
2.1. Segmented Approach
In a segmented approach:
- We assume there are 256 * 5 discreet colors (1,280)
- We map n to a value in 0..1279, and we call this “b”
- We figure out which segment by dividing b by 256
- The segment number (0 to 5) gives us a set of rules for generating r, g, and b values
- We use the rules, along with the remainder “q” to generate r, g, and b
From a code perspective, this is clunky and inefficient, but it can be done with only integer operations, making it relatively fast.
But it’s not very fun…
3. Sine Waves
If we look at just the green channel as an example, we see that it rises and falls, and because of its cyclic nature, we can overly a sine wave.
Let’s look at the anatomy of a sine wave.
Sine waves fluctuate between +1 and -1 as a function of a specified angle.
The distance around a circle is 2 * π * radius, or 2πr.
If we consider a “unit circle” centered at (0,0) whose radius is 1, then the distance around the circle is simply 2π, and we can use a portion of this distance to measure angular distance.
- At 0 degrees, we start to the right of the circle’s center, at (1,0).
- As we move counterclockwise,the top of the circle is π/2 radians, at (0,1).
- The left side of the circle (half-way around) is π radians, at (-1,0).
- The bottom of the circle is 3π/2 radians (0,-1).
- As we continue to move counterclockwise, we reach 2π radians, but this is also our starting point at 0 radians.
Throughout this process, the sine function describes the “y” coordinate:
- 0 at 0 degrees (and 2π degrees)
- +1 at π/2 degrees
- 0 at π degrees
- -1 at at 3π/2 degrees
4. Mapping Sine Waves
We have three things to solve, in order to utilize sine waves:
- Map our arbitrary integer “n” to the interval of 0..2π
- Scale, shift, and clamp the amplitude, so that the function returns 0..255 (our RGB channels must have a value in this range)
- Shift the peak amplitude left or right to align with each channel
4.1. Map “n” to 2π
The first task is to map any arbitrary value of “n” to a point on the unit circle, from 0 radians to 2π radians.
This is actually fairly simple to accomplish:
- We assume “m” is the maximum value of “n”, and is passed as a parameter to our function.
- If we divide n/m, we get a number in the range 0..1.
- As n approaches 0, n/m approaches 0.
- As n approaches m, n/m approaches 1.
- We then multiply n/m by the total distance around our unit circle, which is 2π.
- The result is an angle that we can use for the sine function.
4.2. Fix the Amplitude
The next step is to adjust the amplitude so that our function returns a useful value.
The sine function returns a value from -1 to +1, but we require a value in the range 0..255.
In order to get a useful value, we will need to scale the sine wave so that its amplitude is a useful height, shift it so that the part we want is above the zero line, and clamp the output so that we don’t exceed the 0..255 range.
4.2.1 Scale and Shift
The first step is to scale the amplitude.
Keep in mind that the range of sine(n) is 2 – it goes from -1 to +1.
Therefore, any value that we use to scale the range will result in a range of double that value.
For example, if we multiply the sine output by 127, the resulting range would be -127 to +127. Although this would give us 255 effective values, each channel would only reach full intensity at one specific point, and lowest intensity at one specific point.
Looking back at what we want our function to do, we actually want each channel to have full intensity or lowest intensity for a greater range than just one point.
If we multiply by 192, the range -192 to +192 gives us more values than we need (384 compared to 256), and we can use clamping (later) to create a plateau at the top and bottom that more closely matches our desired profile.
The next step is to shift the part of the wave we need above the zero line:
y=sin(a) * 192 + 128
Before clamping, “y” will now be in the range of -64 to +320.
The last thing we need to do to the amplitude is to “clamp” (limit) the range to useful values.
In the graph above, our function returns values along the yellow line (-64 to +320), but we want to “clamp” or limit the returned value to the range between the green lines, which is 0 to 255.
Effectively, we want values < 0 to be clamped to 0, and values > 255 to be clamped to 255.
Unfortunately, the easiest way to do this is to use IF commands:
if(y<0) y=0 if(y>255) y=255
Some languages support the conditional assignment operator:
This effectively says:
if(y<0) y=0 else if(y>255) y=255 else y=y
The most effective way to clamp a value is to use the “min” and “max” functions, because this is what they are designed to do. However, not every language supports them:
If y>255, “min” clamps it to 255. If the output of min < 0, “max” clamps it to 0.
4.3. Shifting Peak Amplitude
Before we go on, let’s take a pause.
- Our function f(n,m) will return a 32-bit color based on how close n is to m
- We’ve mapped all possible values of n to the unit circle, based on m being congruent to 2π
- We’ve scaled the amplitude to a useful range, moved the critical portion above the zero line, and clamped the output
The last thing we need to do is align the peak of our sine function with each of the three RGB channels.
4.3.1 Utility Shift
The first thing we need to do is line up our sine wave so that its peak is in a useful position.
Our sine wave starts off with y=0, and at π/2, y=+1.
However, our first channel, red, starts high (+1) and then goes to 0.
We can shift the entire wave by adding π/2 to the angle before we apply sine.
4.3.2 Color Channels
Each channel takes up 1/3 of the spectrum, which is spread across the unit circle whose circumference is 2π.
After our utility shift, red starts off high at 0, so we need green to peak 1/3 of the way past that, or 2π/3.
And, blue peaks at 2/3 past that, or 2*2π/3, or 4π/3.
If we take our angle “a”, we can feed it in to three different versions of our sine function, each shifted by 1/3.
r=sin(a)*192+128 g=sin(a - 2π/3)*192+128 b=sin(a - 4π/3)*192+128
4.4. One Last Cleanup Task
At this point, we’re basically done.
However, in the graph above, we see the spectrum loop from red (0 degrees) all the way back around to red (2π degrees).
The last 1/6 goes from magenta (red and blue both high) to red, as blue drops to zero, and we don’t want any duplicate or ambiguous color values.
To eliminate this last portion, we subtract it before we scale n, so we subtract 1/6 of 2π. 2π/6 = π/3.
a=(2π - π/3)n/m + π/2
Which simplifies to:
a=5πn/3m + π/2
4.5. Putting it All Together
…and here is our finished function!
To convert the r, g, and b values to a 32 bit ARGB, we use some fancy bit shifting:
We start with 0xFF, that will become the alpha value for fully-opaque. << is the bit shift operator. 0xFF << 8 = 0xFF00 || is the bitwise-or operator. 0xFF00 || r = 0xFFrr 0xFFrr << 8 = 0xFFrr00 0xFFrr00 || g = 0xFFrrgg 0xFFrrgg << 8 = 0xFFrrgg00 0xFFrrgg00 || b = 0xFFrrggbb
There are a couple of useful variants.
5.1. Use a Portion of the Spectrum
To use just a portion of the spectrum, we need to know two things:
- Width: What portion do we map to n
- Offset: Where do we start (0 position)
The position corresponding to 0, p(0), is our Offset.
If we subtract our maximum position p(m) from p(0), we get our width.
In the example above, we want to return a color in the range of green to blue, so our starting point, p(0) is the point corresponding to green, 2π/3. Our ending point, p(m), is the point corresponding to blue, 4π/3.
Therefore our width is 4π/3 – 2π/3, which is 2π/3.
To get the shortened portion of the spectrum, we change our mapping of n, using our new values:
a=width * n/m + π/2 + offset
In this example:
a=2π/3 * n/m + π/2 + 2π/3
5.2. In Reverse
Let’s say you want the colors to appear in reverse – in our example above, let’s say you want green for larger values of n, and blue for smaller values.
This is easy to accomplish by using m to complement n:
Where n/m returns 0 as n approaches 0, and 1 as n approaches m, complementing n will result in the opposite.