The goal of colorpath is to help you design sequential (and diverging) color palettes that are effective and perceptually uniform.

At the moment, this vignette has an audience of one. As we get our legs under us, this vignette will become more-accessible.

This package uses the idea of a color path; I use this term to describe a trajectory through LUV colorspace from which one could extract sequential palettes for each of light and dark mode. This way, a light-mode palette and a dark-mode palette can look like they came from the same place.

There are a few principles this package rests on:

  1. The HCL colorspace is a relatively intuitive way to describe colors.

  2. The LUV colorspace is approximately perceptually-uniform; i.e. the Euclidean distance between two colors in LUV space corresponds well with the perceptual difference between those colors.

  3. The Euclidean distance along a Bézier spline can be computed readily.

Usage

The idea is to start with a data frame of HCL values - these will be coordinates of control-points for a Bézier spline. If you want to learn more about the HCL colorspace, the folks who develop the colorspace package offer a comprehensive introduction, a color-picker app, as well as other apps.

Proscribe colors

This package has a such a sample HCL dataset:

df_hcl_blues
#> # A tibble: 3 × 3
#>       h     c     l
#>   <dbl> <dbl> <dbl>
#> 1   250     0    30
#> 2   250   150    60
#> 3   250     0    90

A few things to notice here:

  • there are three columns: h, c, and l for hue, chroma, and luminance

  • luminance goes from low to high

  • chroma begins and ends at zero, has a peak in the middle

  • hue is constant

As you will see, this is not the most exciting set of blues. There should be more chroma. At some point a better demonstration set should be chosen, but it gets the job done for now. For example, there is no reason to have only three control points, and there is no reason you can’t vary the hue. That said, I think it is a good idea that, for a given “single-ish hue” color path, the hue should vary linearly with the luminance. s We can convert this data frame into an LUV matrix:

mat_luv_blues <- as_mat_luv(df_hcl_blues)

round(mat_luv_blues, 3)
#>       l       u        v
#> [1,] 30  -0.003   -0.009
#> [2,] 60 -51.303 -140.954
#> [3,] 90  -0.003   -0.009

The LUV space is a Cartesian form of the HCL space; HCL is a polar form of LUV. If chroma is zero, we should expect u and v both to be zero; we can see that this is not the case here. This is because the function as_mat_luv() has an argument chroma_min whose default is 0.01.

This is a trick used to preserve the hue of a color if its chroma is zero; a color with chroma value of 0.01 is imperceptible from thE corresponding color with a chroma value of 0.

Also, for convenience, mat_luv_blues is an exported dataset.

Simple path

The coordinates-set mat_luv_blues represents a set of control points for a Bézier spline in LUV space.

Let’s create the spline:

path_blues <- pal_luv_bezier(mat_luv_blues, rescale_path = FALSE)

path_blues
#> function(x): 
#>   input:  vector (0 <= x <= 1)
#>   output: matrix LUV values
#> 
#>   Bézier spline based on these control points:
#>       l       u        v
#> [1,] 30  -0.003   -0.009
#> [2,] 60 -51.303 -140.954
#> [3,] 90  -0.003   -0.009

As you can see, this is a palette function. It takes a numeric input; however, instead of hex-codes, it returns LUV values:

path_blues(c(0, 0.5, 1)) %>%
  round(3)
#>       l       u       v
#> [1,] 30  -0.003  -0.009
#> [2,] 60 -25.653 -70.482
#> [3,] 90  -0.003  -0.009

We can create a ggplot to see what this path looks like in the chroma-luminance plane. The plot function takes the palette function as an argument.

plot_cl(path_blues)

In the plot, we see three things:

  • points in the palette
  • the control points for the Bézier spline
  • for each point plotted from the palette, the color with the maximum chroma, given the hue and luminance.

One of the plot specifications is that the chroma axis be scaled proportionately to the luminance axis; we specify the aspect ratio to be one.

When we created path_blues, pal_luv_bezier() used an option rescale_path = FALSE. In the plot, you will see that the points in the palette are equally spaced in luminance. However, they are not equally spaced in terms of the distance along the path.

Underneath the hood, we use the bezier package to calculate the splines. It also has a function to calculate the distance along the path. We can use this information to rescale the input to the palette function to be perceptually uniform, insofar as LUV space is perceptually uniform.

Rescaled path

In pal_luv_bezier(), the default value for rescale_path is TRUE. This invokes the distance calculation, which can take a few seconds, then rescales the function input:

pal_blues_rescaled <- pal_luv_bezier(mat_luv_blues)

plot_cl(pal_blues_rescaled, label_hue = TRUE)

You’ll notice a couple of things: the palette points are equally-spaced along the color path, and there are now labels for the value of the hue for each control point. These labels can be informative while developing a color path, but I can see how they might distract from a “finished product”.

Path vs. Palette

In my mind a color path contains palettes or parts of palettes - the idea would be, say for a corporate palette, to have a series of color paths, say a set of blues, oranges, and greens. From these paths sequential and diverging palettes could be composed from parts of these paths.

Sequential palette (light mode)

In my mind, a sequential palette for light mode should have high luminance at the low end of the scale and low luminance at the high end of the scale.

We can make such a palette by applying a rescaler function to the path function. One of the rescaler functions work using the luminance of a the path function - in essence, you specify the luminance values you want to form the ends of the color scale.

# there is an opportunity to make this cleaner, I don't like to specify 
#  pal_blues_rescaled twice
rsc_light <- rescaler_lum(c(85, 40), pal_blues_rescaled)

pal_blues_light <- pal_luv_rescale(pal_blues_rescaled, rsc_light)

plot_cl(pal_blues_light)

Sequential palette (dark mode)

For dark mode, the low end of the scale should have colors with low luminance; the high end of the scale should have colors with high luminance:

# there's a shortcut now: pal_luv_rescale_lum()
pal_blues_dark <- pal_luv_rescale_lum(pal_blues_rescaled, c(35, 80))

plot_cl(pal_blues_dark)

To use a sineramp, the input needs to be an integer, the output needs to be hex. We have functions for those.

Here’s a bit more detail on as_pal_disc() and as_pal_hex(); these each take a palette function, and each return a palette function:

n <- 6
x <- seq(0, 1, length.out = 6)
x
#> [1] 0.0 0.2 0.4 0.6 0.8 1.0

# input x, output LUV
pal_blues_dark(x)
#>             l          u         v
#> [1,] 35.00001  -7.840873 -21.54261
#> [2,] 40.19825 -14.478194 -39.77851
#> [3,] 47.13513 -20.936366 -57.52220
#> [4,] 60.29520 -25.650735 -70.47482
#> [5,] 73.14525 -20.728530 -56.95116
#> [6,] 79.99949 -14.253904 -39.16226

# input x, output hex
as_pal_hex(pal_blues_dark)(x)
#> [1] "#45536C" "#41608C" "#3F72B0" "#5993DF" "#8EB5F4" "#AFC7F4"

# input n (number of colors), output hex
as_pal_disc(as_pal_hex(pal_blues_dark))(n)
#> [1] "#45536C" "#41608C" "#3F72B0" "#5993DF" "#8EB5F4" "#AFC7F4"

At this point, I’m not terribly happy with the results. I do not perceive this to be as uniform as I hoped. Speculating:

  • The “curtain” seems washed out at both ends. I think this is due to the sharp chroma peak
  • I am not as hopeful that individual palettes can be extracted from paths because I think we are asking too much of a path, especially because we are working with a luminance range of 60.
  • Perhaps this can still be a useful tool to create related palettes?

Of course, I could have chosen a bad starting point and become discouraged.

What I think (hope) this package still offers is a way to design a palette reproducibly, capturing your design intent as code or parameters.

New set of ideas: let’s define a surface in within the gamut such that the hue is a monotonic function of luminance.

We can create such a function.

sfc_blues <- surface_hl(c(240, 260))

sfc_blues(seq(0, 100, by = 20))
#> [1] 240 244 248 252 256 260

We can plot what such a function looks like in the gamut:

plot_surface_hl(sfc_blues)

Using this slice through the gamut, we can define an HCL data fram (then an LUV matrix) of control points using a data frame in CL, and a surface function:

df_cl <- tibble(l = c(20, 50, 80), c = c(0, 150, 0))

df_hcl_blues_new <- df_hcl(df_cl, sfc_blues)
df_hcl_blues_new
#> # A tibble: 3 × 3
#>       h     c     l
#>   <dbl> <dbl> <dbl>
#> 1   244     0    20
#> 2   250   150    50
#> 3   256     0    80

mat_luv_blues_new <- as_mat_luv(df_hcl_blues_new)

Now you can create a palette:

pal_blues_new <- pal_luv_bezier(mat_luv_blues_new)

plot_cl(pal_blues_new)

Perhaps palettes in the same “family” need not be drawn from the same colorpath - just from the same surface.

Surfaces

This is an attempt to re-boot this vignette; once these sections are on their feet, they will start to “take over” the rest of the vignette.

Maybe I should just try to start at the start. Suffice to say that in its early form, these thoughts are not yet properly attributed. For me, it all starts with the colorspace package, and Achim Zeileis’ talk at UseR!2019.

In day-to-day use, colors on a computer display are expressed using hex-codes, e.g. #663399. As you likely know, hex-codes express the color in terms of three color channels, or dimensions: red, green and blue. These three dimensions form a color space called sRGB. A color space is a set of dimensions used to express colors.

The set of all colors that can be expressed using sRGB is called the sRGB gamut. It is a subset of the visible gamut, i.e. the set of all colors that can be perceived (generally) by the human eye. Because we are concerned only with colors displayed on monitors, we concern ourselves with the sRGB gamut.

Here’s what the sRGB gamut looks like in the sRGB color space (source):

sRGB gamut in sRGB space, by Michael Horvath

sRGB gamut in sRGB space, by Michael Horvath

In this representation, the origin (RGB = 0, 0, 0) is the color black, and is hidden in the back corner. The color white (RGB = 1, 1, 1) is the corner “nearest” us. This is an additive color space: the primary colors: red, green, and blue, are at three of the vertices. The secondary colors: cyan, magenta, and yellow, are at the three remaining vertices.

This colorspace has a concrete representation - it’s very easy for we humans to think of it as a cube. But there’s a problem with sRGB space, it’s not perceptually uniform. Consider two different colors (this would be a great place for an example). We can measure how far apart they are in a color space (using Euclidean distance); we can also make a judgment on how “differently” the two colors are perceived (by a human). If a color space is perceptually uniform, then the color-space distance between two colors in that space is proportional to the perceptual difference between the colors. The sRGB color space is not perceptually uniform.

The reason we are concerned with perceptual uniformity is because a continuous color-palette maps numerical values to colors. We want to make sure that differences in color are interpreted as faithfully as possible as differences in value.

There are color spaces that are more perceptually-uniform than sRGB. Consider the HCL color space, named after its dimensions: hue, chroma, and luminance. We can show the same set of colors, the sRGB gamut, in the HCL color space (source):

sRGB gamut in HCL space, by Michael Horvath and Christoph Lipka

sRGB gamut in HCL space, by Michael Horvath and Christoph Lipka

Here, we see a distorted view of the original cube. This can help us to define the terms:

  • Luminance describes how light or dark a color is; a lighter color has greater luminance. The domain of luminance is 0 to 100.

  • Chroma describes how colorful a color is; a grey has no chroma, a bright red has a lot of chroma. The maximum value of chroma depends on the hue and the luminance, i.e. the distorted cube. The largest value for chroma in the sRGB gamut is about 180 for a mid-luminant red.

  • Hue describes the particular color, e.g. a red vs. a green. It is cyclical; hue is expressed as an angle, in degrees.

Hue, chroma, and luminance give us an intuitive and tangible way to describe color, using a color space where distance is reasonably proportional to human perception.

There is another color space we should introduce; it is identical to the HCL color space, except it uses Cartesian coordinates instead of polar coordinates. This is the LUV color space (source):

sRGB gamut in LUV space, by Michael Horvath and Christoph Lipka

sRGB gamut in LUV space, by Michael Horvath and Christoph Lipka

The concept of luminance is the same as with HCL. The coordinates U and V are the Cartesian correspondences to hue and chroma. The LUV color space is less intuitive than the HCL color space, but it allows a straightforward calculation of Euclidean distance.

In this package, we want to:

  • think and specify using the HCL color space.
  • calculate using the LUV color space.
  • express the colors using the sRGB color space (hex codes).

Thoughts on continuous palettes

Luminance propels everything. Chroma used to create a longer path (larger color-range). Be careful with hue because differences in hue are perceived differently by folks with color-vision deficiency.

Dark mode. Comparing prominence vs. the background.

sfc_cyan <- surface_hl(192.2)
sfc_blue <- surface_hl(265.9)

We get warnings (from ggplot2) when we render these plots because the second y-axis has a uses the surface-functions which, in these cases, have a constant hue.

plot_surface_hl(sfc_cyan)

plot_surface_hl(sfc_blue)

sfc_multi_blue <- surface_hl(c(271, 215))
plot_surface_hl(sfc_multi_blue)

Let’s think about some trajectories to plot along this surface. Want to think in terms of light mode and dark mode.

In light mode, let’s set our luminance ceiling to 90; in dark mode, let’s set the luminance floor to 30.

dfcl_blues_light <- 
  tribble(
      ~l,      ~c,
      90.0,  30.0,
      75.0,  80.0,
      50.0,  80.0,
      30.0,  60.0,
      20.0,  40.0
  )
dfhcl_blues_light <- df_hcl(dfcl_blues_light, sfc_multi_blue)

dfhcl_blues_light
#> # A tibble: 5 × 3
#>       h     c     l
#>   <dbl> <dbl> <dbl>
#> 1  221.    30    90
#> 2  229     80    75
#> 3  243     80    50
#> 4  254.    60    30
#> 5  260.    40    20
pal_blues_light <- 
  pal_luv_bezier(as_mat_luv(dfhcl_blues_light), rescale = TRUE)

plot_cl(pal_blues_light)

hcl <- 
  pal_blues_light(seq(0, 1, 0.1)) %>% 
  farver::convert_colour(from = "luv", to = "hcl") %>%
  print()
#>              h        c        l
#>  [1,] 220.6000 30.00000 90.00001
#>  [2,] 224.5388 40.18375 86.43013
#>  [3,] 227.7719 49.90009 81.82097
#>  [4,] 230.9627 58.63841 75.79317
#>  [5,] 234.4944 65.36962 67.96905
#>  [6,] 238.4971 68.55648 58.56516
#>  [7,] 242.7446 67.53435 48.85036
#>  [8,] 247.0135 63.25123 39.93235
#>  [9,] 251.2849 56.84308 32.12757
#> [10,] 255.5890 48.97409 25.45842
#> [11,] 259.8000 40.00000 20.00000

sfc_multi_blue(hcl[, "l"])
#>  [1] 220.6000 222.5991 225.1803 228.5558 232.9373 238.2035 243.6438 248.6379
#>  [9] 253.0086 256.7433 259.8000

The two plots seem inconsistent. The spline does not respect the surface. It respects only the control points, which are on the surface.

blues_light_discrete <- as_pal_disc(as_pal_hex(pal_blues_light))

swatch(blues_light_discrete(11))
pal.sineramp(blues_light_discrete)

Let’s try a variation on the theme to create a half-palette for a diverging-palette:

dfcl_blues_div_light <- 
  tribble(
      ~l,      ~c,
      90.0,   0.0,
      85.0,  50.0,
      70.0,  80.0,
      50.0,  80.0,
      30.0,  60.0,
      20.0,  40.0
  )
dfhcl_blues_div_light <- df_hcl(dfcl_blues_div_light, sfc_multi_blue)

dfhcl_blues_div_light
#> # A tibble: 6 × 3
#>       h     c     l
#>   <dbl> <dbl> <dbl>
#> 1  221.     0    90
#> 2  223.    50    85
#> 3  232.    80    70
#> 4  243     80    50
#> 5  254.    60    30
#> 6  260.    40    20
pal_blues_div_light <- 
  pal_luv_bezier(as_mat_luv(dfhcl_blues_div_light), rescale = TRUE)

plot_cl(pal_blues_div_light)

blues_div_light_discrete <- as_pal_disc(as_pal_hex(pal_blues_div_light))

swatch(blues_div_light_discrete(11))
pal.sineramp(blues_div_light_discrete)

Dark mode

Here’s my theory (and I’m sure it is not original): a sequential palette “move away” from the background color. For light mode, it start more luminant and become less luminant. For dark mode, it should start less luminant and become more luminant.

In essence, you want the scale to emphasize the “interesting” data by placing those colors as far away from the background as possible.

Here, we assume that light mode has a white background (luminance 100), and that the dark-mode background has a luminance of 20. This has the effect of shortening the available-luminance range for dark mode (values under 20 are inaccessible).

That said, let’s look at what dark mode might look like here:

dfcl_blues_dark <- 
  tribble(
      ~l,      ~c,
      35.0,  30.0,
      50.0,  80.0,
      75.0,  80.0,
      90.0,  30.0
  )

dfhcl_blues_dark <- df_hcl(dfcl_blues_dark, sfc_multi_blue)
pal_blues_dark <-
  pal_luv_bezier(as_mat_luv(dfhcl_blues_dark), rescale = TRUE)

plot_cl(pal_blues_dark)

blues_dark_discrete <- as_pal_disc(as_pal_hex(pal_blues_dark))

swatch(blues_dark_discrete(11), background = "#333", margin = 10)
pal.sineramp(blues_dark_discrete)

dfcl_blues_div_dark <- 
  tribble(
      ~l,      ~c,
      35.0,   0.0,
      40.0,  40.0,
      50.0,  80.0,
      75.0,  80.0,
      90.0,  30.0
  )

dfhcl_blues_div_dark <- df_hcl(dfcl_blues_div_dark, sfc_multi_blue)
pal_blues_div_dark <-
  pal_luv_bezier(as_mat_luv(dfhcl_blues_div_dark), rescale = TRUE)

plot_cl(pal_blues_div_dark)

blues_div_dark_discrete <- as_pal_disc(as_pal_hex(pal_blues_div_dark))

swatch(blues_div_dark_discrete(11), background = "#333", margin = 10)
pal.sineramp(blues_div_dark_discrete)

A couple notes on this diverging palette:

  • To my eyes, the LUV space is not quite perceptually uniform. The first few colors in this palette, near the greys, seem closer together than the lighter colors do.

  • For an actual diverging palette, I think I would cut the palette off at luminance around 80, as a consideration for the other side of the palette (not yet seen). For example, I would not the light orange to appear too close to the light blue.

There is a way to test this idea about perceptual uniformity. There is now a function in colorpath, get_distance() that given an LUV palette-function and a number of intervals, calculates the perceptual distance covered by each of the intervals.

get_distance(pal_blues_div_dark, n = 10)
#>  [1] 7.388427 5.372865 4.891737 5.406694 7.086043 9.265223 8.712178 5.940375
#>  [9] 4.629859 4.328111

This shows the “cie2000” perceptual-distance between adjacent colors. It does show a “lull” in the first part of the palette, although the leading value (~7.4) surprises me.

I think we can solve one problem with another here. We can use a Bézier spline only to define the luminance-chroma trajectory, then use the surface to define the hue according to the luminance. Although colorspace offers a bunch of parameters to define a palette function, colorspace’s math uses the parameters with the same separation; the hue is calculated independently from the chroma.

By splining only on chroma and luminance, we lose effectiveness in the distance rescaling that the bezier package offers. However, using get_distance(), we could make our own rescaling - which may be more effective than Euclidean distance in LUV space.

Interim summary

I think that defining surfaces can make our problem simpler because it separates our variables according to our concerns.

We could define surfaces (hue as function of luminance) to represent palette families: greens, blues, etc. We could define trajectories (in luminance and chroma) to represent palette type: light, light-diverging, dark, dark-diverging.

Surfaces are chosen with a few factors in mind:

  • open up more chroma space
  • fidelity to an organization’s brand
  • among singlish-hue surfaces, blues and oranges seem like default choices because of their contrast with each other, especially for folks with CVD

Trajectories are also chosen with a few factors in mind, all of them have to do with the background color for each of light and dark mode. All colors in all palettes should have luminance values at least 10-points different from the luminance of the background color (I choose 10 as an example, maybe the right value is 7). This means that if your dark-mode background has a luminance of 20, the minimum luminance for dark-mode palettes should be 30.

  • for light mode, assuming a background luminance of 100, we could start the a trajectory with luminance 90, then end with a luminance that need not respect the luminance considerations of dark mode, i.e. we could go down to luminance 20.
  • for dark mode, we should start with a luminance at the luminance limit.
  • for non-diverging palettes, we could start a trajectory with a reasonable chroma - no need to be “too grey”.
  • for diverging palettes, we need to start the trajectory with zero chroma, in order to respect the common point.
  • again for diverging palettes, the end of each arm should be not-too-far from the maximum chroma, as the chroma is what will drive the contrast between the two extrema of a “combined” diverging palette.

Perhaps a set of templates could be used for these trajectories, then adapted to the chroma peculiarities of each surface family.

Joining palettes

Diverging palette (light mode)

Diverging palette (dark mode)