Extended Exercise: Color Palettes
In this exercise we will explore the creation of color palettes. An attractive picture must make good choices for color. Color theory has developed to explain combinations of color that go together. We will use color theory to create programs that can automatically construct attractive color palettes.
Color Representation
In Doodle we can represent a color in one of two ways:
- as triples containing red, green, and blue values (RGB); or
- as hue, saturation, and lightness (HSL).
We will use the HSL representation as it better corresponds to our perception of color. If we arrange colors in the familiar color wheel, distance from the center corresponds to lightness and steps around the outside correspond to changes in hue:
Saturation, the third dimension, corresponds to intensity of color. The strip of colors below shows the effect of varying saturation from 0.0 to 1.0, for fixed hue (170 degrees) and lightness (0.5). As you can see, changing saturation goes from a dull gray to a bright and vibrant color.
The Color API
Before we can create color schemes we need to know how to create and manipulate colors.
Creating Colors
There are two main methods to create colours:
Color.hsl(hue: Angle, saturation: Normalized, lightness: Normalized)
Color.rgb(red: UnsignedByte, green: UnsignedByte, blue: UnsignedByte)
These methods use types --- Angle
, Normalized
, and UnsignedByte
--- that have not seen before.
They all represent numbers with some special characteristics.
A Normalized
is a number between 0 and 1.
An UnsignedByte
is an integer between 0 and 255.
An Angle
is unrestricted in value but there are several operations that only make sense on angles (sine, cosine, and so on) and several representations (angles, radians) in common use.
The Normalized
and UnsignedByte
types make it explicit that some conversion is necessary from raw number types like Int
and Double
.
There are many different ways to handle inputs that are out of range,
such as clipping them or raising an error,
and we require the programmer to be explicit about the approach they want.
For Normalized
and UnsignedByte
Doodle provides a default conversion of clipping.
For example, if we are creating a Normalized
(value between 0.0 and 1.0),
any input less than 0.0 is set to 0.0 and greater than 1.0 becomes 1.0.
To use these conversions import doodle.syntax.normalized._
or doodle.syntax.uByte._
and then numbers are enriched with methods normalized
and uByte
respectively.
Here's a quick example. Notice how values out of range are set to the closest valid value.
import doodle.syntax.normalized._
0.5.normalized
//res: doodle.core.Normalized = Normalized(0.5)
0.0.normalized
//res: doodle.core.Normalized = Normalized(0.0)
-0.5.normalized
//res: doodle.core.Normalized = Normalized(0.0)
1.5.normalized
//res: doodle.core.Normalized = Normalized(1.0)
import doodle.syntax.uByte._
128.uByte
//res: doodle.core.UnsignedByte = UnsignedByte(0)
0.uByte
//res: doodle.core.UnsignedByte = UnsignedByte(-128)
255.uByte
//res: doodle.core.UnsignedByte = UnsignedByte(127)
-127.uByte
//res: doodle.core.UnsignedByte = UnsignedByte(-128)
512.uByte
//res: doodle.core.UnsignedByte = UnsignedByte(127)
For Angle
we ask the programmer to specify if the raw number represents a value in degrees, radians, or turns (fractions of a circle, with a full circle being one turn).
For Angles
the import is doodle.syntax.angle._
which enriches numbers with methods degrees
, radians
, and turns
.
Here's an example:
import doodle.syntax.angle._
0.degrees
//res: doodle.core.Angle = Angle(0.0)
180.degrees
//res: doodle.core.Angle = Angle(3.141592653589793)
360.degrees
//res: doodle.core.Angle = Angle(6.283185307179586)
math.Pi
//res: Double = 3.141592653589793
math.Pi.radians
//res: doodle.core.Angle = Angle(3.141592653589793)
0.5.turns
//res: doodle.core.Angle = Angle(3.141592653589793)
1.0.turns
//res: doodle.core.Angle = Angle(6.283185307179586)
We can now create some colors:
Color.hsl(170.degrees, 1.0.normalized, 0.5.normalized)
// res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(1.0))
Note that the color we created has four fields. The fourth field is the alpha
value, which specifies the opacity of the color. There are four parameter methods Color.hsla
and Color.rgba
that can be used to specify the alpha
when creating a color.
Modifying Colors
There are several methods to modify colors. These methods all create a new Color
. No Color
is ever actually changed after it is created, as doing so breaks substitution.
New hue
, saturation
, lightness
, and alpha
values can all be set with methods of the same name. Notice how the original color is unchanged.
val c = Color.hsl(170.degrees, 1.0.normalized, 0.5.normalized)
//c: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(1.0))
c.hue(220.degrees)
//res: doodle.core.Color = HSLA(Angle(3.839724354387525),Normalized(1.0),Normalized(0.5),Normalized(1.0))
c.saturation(0.5.normalized)
//res: doodle.core.Color = HSLA(Angle(2.9670597283903604),Normalized(0.5),Normalized(0.5),Normalized(1.0))
c.lightness(0.25.normalized)
//res: doodle.core.Color = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.25),Normalized(1.0))
c.alpha(0.5.normalized)
//res: doodle.core.Color = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(0.5))
c
//res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(1.0))
There are also methods to adjust the existing hue
, saturation
, lightness
, and alpha
. These methods all create a new color by adding or subtracting from the existing value of the parameter of interest.
val c = Color.hsl(170.degrees, 1.0.normalized, 0.5.normalized)
//c: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(1.0))
c.spin(220.degrees)
//res: doodle.core.HSLA = HSLA(Angle(6.806784082777885),Normalized(1.0),Normalized(0.5),Normalized(1.0))
c.lighten(0.2.normalized)
//res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.7),Normalized(1.0))
c.darken(0.2.normalized)
//res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.3),Normalized(1.0))
c.saturate(0.2.normalized)
//res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(1.0))
c.desaturate(0.2.normalized)
//res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(0.8),Normalized(0.5),Normalized(1.0))
c.fadeIn(0.2.normalized)
//res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(1.0))
c.fadeOut(0.2.normalized)
//res: doodle.core.HSLA = HSLA(Angle(2.9670597283903604),Normalized(1.0),Normalized(0.5),Normalized(0.8))
Complementary Colors
A simple way to generate colors that look good together is to use complementary colors. Given a color, it's complement is the one opposite it on the color wheel. In other words, it has hue rotated by 180 degrees. Complementary pairs have high contrast and make for striking compositions:
Exercise: Complementary Colors
Create a method complement
that takes a Color
as input and returns its complement.
You can use the method spin
on a Color
to rotate its hue by a given Angle
.
<div class="solution">
def complement(color: Color): Color =
color.spin(180.degrees)
</div>
Exercise: Complementary Chess Boards
Using complement
write a method complementaryChessBoard
that creates a four-by-four chess board using a complementary color scheme. This method should take a Color
input. Here's the method signature:
def complementaryChessBoard(color: Color): Image = ???
You should end up with a picture like the below.
<div class="solution"> We can build the method using the methods we have already created.
def complementaryChessBoard(color: Color) =
fourByFour(color, complement(color))
</div>
Analogous Colors
Complementary colors can be quite harsh on the eyes. We can play around with saturation and lightness to decrease the contrast but ultimately this color scheme is quite limited. Let's explore another color scheme, analogous colors, that gives us more flexibility.
In analogous color is simply one that is close on the color wheel to a given color. We can generate an analogous color by spinning hue, say, fifteen degrees.
Exercise: Analogous Colors
Create a method analogous
that takes a Color
as input and returns an analogous color.
<div class="solution">
def analogous(color: Color): Color =
color.spin(15.degrees)
</div>
Exercise: Analogous Chess Boards
Now create a method analogousChessBoard
that creates a four-by-four chess board with an analogous color scheme. You should get a result like the below.
<div class="solution">
This follows the same pattern as complementaryChessBoard
. Notice how we build big things (a colored chess board) out of smaller component parts. This idea of composing small pieces of code into larger pieces is one of the key ideas in functional programming.
def analogousChessBoard(color: Color) =
fourByFour(color, analogous(color))
</div>
Beyond Two-Color Palettes
We have seen how we can build very simple color palettes from complementary and analogous colors. Now let's combine these ideas to build more complex palettes. A tetrad color scheme consists of two analogous colors and their complements.
Define a method tetradChessBoard
that creates a chess board colored with a tetradic color scheme as illustrated. Use the following skeleton
def tetradChessBoard(color: Color) = ???
Hint: You will have to call twoByTwo
, not fourByFour
, within the body of tetradChessBoard
.
<div class="solution"> It would be nice to have a method for creating an entire tetradic color scheme from a single color, but we don't currently have a way of wrapping up a collection of data so that we could return all four values from the methods. We'll see ways of doing this later, when we introduce classes and collections.
def tetradChessBoard(color: Color) = {
val color1 = color
val color2 = analogous(color)
val color3 = complement(color)
val color4 = complement(color2)
val square1 = twoByTwo(color1, color3)
val square2 = twoByTwo(color2, color4)
(square1 beside square2) above
(square2 beside square1)
}
</div>