Perl101: Red to Green Gradient
Note: the following technique is not friendly to the color-blind.
I sometimes find that I want to visual data with color. Specifically, I want to use red for "bad" and "green" for good, with a linear gradient in between. As I'm often building Web pages, that means I want:
Color code | Result |
#FF0000 | Bad |
#FFFF00 | Bad-Average |
#FFFF00 | Average |
#7FFF00 | Average-Good |
#00FF00 | Good |
In other words, I want a smooth, linear gradient from red to green for any number in a range. I'm always hitting a search engine for an example, so I figured other people could use this, too.
As you will remember for your HTML color codes, they're often RGB numbers in three two-digit hexadecimal numbers, ranging from 0 (00) to 255 (FF). Thus, red is "FF0000", green is "00FF00" and blue is "0000FF". If you look at the above, you'll see that we start with red, have yellow "FFFF00" as the average and green "00FF00" as the good. In other words, from the bottom to the midpoint, we have red as FF and slide the green from 00 to FF. Then once we hit the midpoint, we keep green at FF and slide the red from FF to 00. Here's one way to write that:
my ( $min, $max ) = ( 1, 11 );
my $middle = ( $min + $max ) / 2;
my $scale = 255 / ( $middle - $min );
for my $num ( 0 .. 12 ) {
if ( $num <= $min ) {
print "FF0000\n";
}
elsif ( $num >= $max ) {
print "00FF00\n";
}
elsif ( $num < $middle ) {
printf "FF%02X00\n" => int( ( $num - $min ) * $scale );
}
else {
printf "%02XFF00\n" => 255 - int( ( $num - $middle ) * $scale );
}
}
Basically, we figure out the $middle where we're going to stop counting up the green and start counting down the red. We also calculate the $scale to know how to scale any given number from 0 to 255.
In the for loop, if we're less than the minimum, we just have red. If we're greater than the maximum, we just have green. For any other number, we figure out which side of the $middle we're on and calculate the 0 to 255 value. The confusing bit, though, might be in the printf value. The %02X format is the magic here. The "X" ensures that we convert our number to an upper-case hex value, while the "02" prefix ensures that the number 12 will be printed as "0C" and not just "C".
That's all well and good, but it's not flexible. I want a subroutine for that. Here's how I don't want to write it:
for my $num ( 0 .. 12 ) {
print gradient(1, 11, $num), "\n";
}
sub gradient {
my ( $min, $max, $num ) = @_;
my $middle = ( $min + $max ) / 2;
my $scale = 255 / ( $middle - $min );
return "FF0000" if $num <= $min; # lower boundry
return "00FF00" if $num >= $max; # upper boundary
if ( $num < $middle ) {
return sprintf "FF%02X00" => int( ( $num - $min ) * $scale );
}
else {
return sprintf "%02XFF00" => 255 - int( ( $num - $middle ) * $scale );
}
}
That actually works and it's a touch cleaner to read (no if/elsif chain), but I don't like having to pass the upper and lower every time, particularly since the task I'm working on now has several potential ranges. Thus, I have this:
my $gradient = gradient( 1, 11 );
for my $num ( 0 .. 12 ) {
print $gradient->($num), "\n";
}
sub gradient {
my ( $min, $max ) = @_;
my $middle = ( $min + $max ) / 2;
my $scale = 255 / ( $middle - $min );
return sub {
my $num = shift;
return "FF0000" if $num <= $min; # lower boundry
return "00FF00" if $num >= $max; # upper boundary
if ( $num < $middle ) {
return sprintf "FF%02X00" => int( ( $num - $min ) * $scale );
}
else {
return
sprintf "%02XFF00" => 255 - int( ( $num - $middle ) * $scale );
}
};
}
Now, instead of recalculating the scale every time, I calculate it once and return an anonymous subroutine reference. Because the sub is generated in the same lexical scope as the data I calculated, we say that it closes over those lexical variables and the returned anonymous subroutine is a closure. It now has its own private state and I can easily make gradients for several ranges:
my $one_to_ten = gradient(1, 10);
print $one_to_ten->(7);
my $next_gradient = gradient( 20, 70 );
print $next_gradient->(33);
In short, we have a nice, flexible gradient generating routine, along with a simple demonstration of a closure.
More importantly, we have a red/green gradient I can easily find using Google :)
#FF0000 |
#FF1100 |
#FF2300 |
#FF3400 |
#FF4600 |
#FF5700 |
#FF6900 |
#FF7B00 |
#FF8C00 |
#FF9E00 |
#FFAF00 |
#FFC100 |
#FFD300 |
#FFE400 |
#FFF600 |
#F7FF00 |
#E5FF00 |
#D4FF00 |
#C2FF00 |
#B0FF00 |
#9FFF00 |
#8DFF00 |
#7CFF00 |
#6AFF00 |
#58FF00 |
#47FF00 |
#35FF00 |
#24FF00 |
#12FF00 |
#00FF00 |
If you want to have fun with this, try adding error checking or making the choice of colors more flexible.
This is quite a naive way to calculate colour gradients, since linear interpolation in the RGB space will yield colours that are not "between" the two limits neither in hue or in intensity. IIRC it's possible to get better results with the appropriate matrix multiplication related to the gamma of your display. Of course, being colour-blind, I'm not the most appropriate person to appreciate the quality of the result :)
@Rafaël: I can believe it's not the best way, but not being an expert in this area, it was the way which seemed rather natural. If you can point to an example where it fails, I'd love to see it (and to be honest, I've no idea what hue or intensity mean in this context).
It's a pure hue shift with no changes to saturation or value (aka intensity).
An accurate red to green gradient would lower the value (from 100 to 50 and back up to 100) as it adjusted the hue. This results in red to reddish orange to rust to a dirty yellow to green which is not as pleasant on the eyes.
I'll code up an accurate red to green gradient tool later today.
If you want a means of producing more accurate gradients (where the hue and value/intensity shift while leaving saturation alone) then you could try the following.
Mr. Muskrat: thanks for that code. That's awesome!
I had to do this a while back. I ended up with a short javascript function which creates a gradient for any number of colours by doing linear interpolation of the HSV values: http://stackoverflow.com/questions/3997241/do-all-my-moose-classes-have-to-contain-namespaceautoclean-and-make-immutabl
oops the previous comment should have this link: http://stackoverflow.com/questions/2593832/how-to-interpolate-hue-values-in-hsv-colour-space
(I can't see how to edit it though)
When I needed to do this, I whipped up Color::Spectrum::Multi, which is a simple wrapper around Color::Spectrum which allows you to specify more than two colours.
It lets you do, e.g.:
Rewritten for PHP with additional options - specify min and max on run (default is 0-100 for percentages) and reverse the order (default is red > green)
function get_gradient($num, $min = 0, $max = 100, $reverse = false) {
}
for ($i=0; $i < 100; $i++) { echo "".get_gradient($i). "
"; }
The color code for Bad-Average in first list with 5 elements is incorrect, should be FF7F00.