This blog post follows up on the previous one. Code in that post finds colors in the HCL color space by sampling points from a sphere centered on a base point. There’s a mismatch between how the sphere is determined (Cartesian coordinates of x, y, L) and the colors are parameterized (polar coordinates of H, C, L), so a conversion happens in the code.

This post looks into what happens if you just start with a “sphere” based on Hue, Chroma, Luminance values. So we’ll set up shapes that expand out from the base point along Hue, Chroma, and Luminance directly.1


Base Point Location


[A Lot of Setup Code]
##---------
# Libraries
##---------
library(tidyverse)
library(patchwork)
library(colorspace)

##------------
# Pick a color
##------------
H_point <- 322
C_point <- 26
L_point <- 69

color_hex <- hcl(H_point, 
                 C_point, 
                 L_point,
                 fixup = FALSE)

##--------------------------------
# See color in H, C, L color space 
##--------------------------------
# C-L Plane ----
get_C_L_plane <- function(H_point) {
  expand_grid(H = H_point,
              C = seq(0, 180, .5),
              L = seq(1, 100, .5)) %>%
    mutate(color_value = hcl(H, C, L, fixup = FALSE)) %>%
    filter(!is.na(color_value))
}
C_L_plane <- get_C_L_plane(H_point)

graph_C_L_plane <- function(C_L_plane, color_hex) {
  ggplot() +
    geom_point(data = C_L_plane,
               aes(C, L, color = color_value, fill = color_value)) +
    scale_x_continuous(labels = abs) +
    scale_color_identity() +
    scale_fill_identity() +
    geom_point(aes(x = C_point,
                   y = L_point),
               color = 'black',
               fill = color_hex,
               shape = 21,
               size = 2) +
    coord_equal()
}
graph_C_L_plane(C_L_plane, color_hex)

# H-L Curve ----
get_H_L_curve <- function(C_point) {
  expand_grid(H = seq(1, 360, 1),
              C = C_point,
              L = seq(1, 100, .5)) %>%
    mutate(color_value = hcl(H, C, L, fixup = FALSE)) %>%
    filter(!is.na(color_value))
}
H_L_curve <- get_H_L_curve(C_point)

label_H_center <- function(H_point, ...) {
  function(x) {(x + (180 - H_point)) %% 360}
}

graph_H_L_curve <- function(H_L_curve, color_hex, H_point) {
  ggplot() +
    geom_point(data = H_L_curve,
               aes((H + (180 - H_point)) %% 360, L, 
                   color = color_value, fill = color_value)) +
    scale_color_identity() +
    scale_fill_identity() +
    geom_point(aes(x = 180, # Because we rotated points to not drop over edge
                   y = L_point),
               color = 'black',
               fill = color_hex,
               shape = 21,
               size = 2) +
    scale_x_reverse('H', # Like you're standing on the inside
                    labels = label_H_center(H_point = H_point),
                    limits = c(360, 0)) +
    coord_equal()
}
graph_H_L_curve(H_L_curve, color_hex, H_point)

# H-C plane ----
get_H_C_plane <- function(L_point){
  expand_grid(H = seq(1, 360, 1),
              C = seq(0, 180, .5),
              L = L_point) %>%
    mutate(color_value = hcl(H, C, L, fixup = FALSE)) %>%
    filter(!is.na(color_value))
}
H_C_plane <- get_H_C_plane(L_point)

graph_H_C_plane <- function(H_C_plane, color_hex) {
  ggplot() +
    geom_point(data = H_C_plane,
               aes(H, C, color = color_value, fill = color_value)) +
    scale_color_identity() +
    scale_fill_identity() +
    scale_x_continuous(breaks = seq(45, 360, 45),
                       minor_breaks = seq(0, 315, 45) + 45/2,
                       labels = c('45', '90', '135', '180', 
                                  '225', '270', '315', '0|360')) +
    scale_y_continuous(limits = c(0, 180)) +
    geom_point(aes(x = H_point,
                   y = C_point),
               color = 'black',
               fill = color_hex,
               shape = 21,
               size = 2) +
    coord_polar(start = 270 * pi / 180,
                direction = -1)
}
graph_H_C_plane(H_C_plane, color_hex)

# C tangent plane ----
C_circle <- data.frame(H = seq(1, 360),
                       C = C_point,
                       color_value = "white")
C_tangent_plane <- expand_grid(x = C_point, # Plane perpendicular to H at C
                               perpendicular_from_C_L = 
                                 seq(-sqrt(180^2 - C_point^2), sqrt(180^2 - C_point^2)),
                               L = seq(1, 100, 1)) %>%
  mutate(x_rotate = x * cos(H_point * pi/180) -  # rotate
           perpendicular_from_C_L * sin(H_point * pi/180),
         y_rotate = x * sin(H_point * pi/180) + 
           perpendicular_from_C_L * cos(H_point * pi/180)) %>%
  mutate(x = x_rotate,
         y = y_rotate) %>%
  select(-x_rotate, -y_rotate)  %>%
  mutate(H = (atan2(y, x) * 180/pi) %% 360,
         C = sqrt(x^2 + y^2)) %>%
  mutate(color_value = "white")
ggplot(data = H_C_plane,
       aes(H, C, color = color_value, fill = color_value)) +
  geom_point() +
  scale_color_identity() +
  scale_fill_identity() +
  scale_x_continuous(breaks = seq(45, 360, 45),
                     minor_breaks = seq(0, 315, 45) + 45/2,
                     labels = c('45', '90', '135', '180', 
                                '225', '270', '315', '0|360')) +
  scale_y_continuous(limits = c(0, 180)) +
  geom_path(data = C_circle) +
  geom_segment(x = H_point,
               y = 0,
               xend = H_point,
               yend = C_point,
               col = "white") +
  geom_point(data = C_tangent_plane, col = "black") +
  geom_point(x = H_point,
             y = C_point,
             color = 'black',
             fill = color_hex,
             shape = 21) +
  coord_polar(start = 270 * pi / 180,
              direction = -1)

get_C_tangent_plane <- function(H_point, C_point) {
  expand_grid(x = C_point, # Plane perpendicular to H at C
              perpendicular_from_C_L = seq(-180, 180, .5),
              L = seq(1, 100, .5)) %>%
    mutate(x_rotate = x * cos(H_point * pi/180) -  # rotate
             perpendicular_from_C_L * sin(H_point * pi/180),
           y_rotate = x * sin(H_point * pi/180) + 
             perpendicular_from_C_L * cos(H_point * pi/180)) %>%
    mutate(x = x_rotate,
           y = y_rotate) %>%
    select(-x_rotate, -y_rotate)  %>%
    mutate(H = (atan2(y, x) * 180/pi) %% 360,
           C = sqrt(x^2 + y^2)) %>%
    mutate(color_value = hcl(H, C, L, fixup = FALSE)) %>%
    filter(!is.na(color_value))
}
C_tangent_plane <- get_C_tangent_plane(H_point, C_point)

graph_C_tangent_plane <- function(C_tangent_plane, color_hex) {
  ggplot() +
    geom_point(data = C_tangent_plane,
               aes(perpendicular_from_C_L, L, 
                   color = color_value, fill = color_value)) +
    scale_color_identity() +
    scale_fill_identity() +
    scale_x_reverse("Distance Perpendicular to C-L Plane",
                    labels = abs) +
    geom_point(aes(x = 0,
                   y = L_point),
               color = 'black',
               fill = color_hex,
               shape = 21,
               size = 2) +
    coord_equal()
}
graph_C_tangent_plane(C_tangent_plane, color_hex)


Let’s first look at all our graphs for our base color. This time I’m picking pink. Next, we can see the C-L Plane, H-C Plane, and H-L Curve images to understand the color in the HCL color space.

Base Color: C-L Plane

Base Color: C-L Plane

Base Color: H-C Plane

Base Color: H-C Plane

Base Color: H-L Curve

Base Color: H-L Curve

Then we can look at the plane tangent to the H-L Curve to see the space perpendicular to the C-L plane.

C Tangent Plane setup

C Tangent Plane setup

Base Color: Plane Perpendicular to C-L Plane

Base Color: Plane Perpendicular to C-L Plane

After the base color is understood, we can see what happens if you extend out to a sphere.


xyL Perimeter


We’ll only pull points on the perimeter for this code, unlike the previous post that had points randomly throughout the sphere. We’ll move away from the base point using the Cartesian coordinates of x, y, and L for the first main section. This is exactly what we did in the last post, so it should look familiar. The main new addition is cutting the shape into pieces and graphing them on facets.

[xyL Perimeter Code]
##------------------
# x, y, L perimeter
##------------------
width <- 15
n_color <- 50 ^ 2

# http://extremelearning.com.au/how-to-evenly-distribute-points-on-a-sphere-more-effectively-than-the-canonical-fibonacci-lattice/#more-3069
get_xyl_data <- function(H_point, C_point, L_point, width, n_color) {
  data.frame(theta = 2 * pi * seq(0, n_color - 1) / ((1 + sqrt(5)) / 2),
             phi = acos(1 - 2 * (seq(0, n_color - 1) + .5) / n_color)) %>%
    mutate(x = cos(theta) * sin(phi),
           y = sin(theta) * sin(phi),
           L = cos(phi)) %>%
    mutate(x = x * width + C_point * cos(H_point * pi/180),
           y = y * width + C_point * sin(H_point * pi/180),
           L = L * width + L_point) %>%
    mutate(H = (atan2(y, x) * 180/pi) %% 360,
           C = sqrt(x^2 + y^2)) %>%
    filter(L >= 0 & L <= 100 & C >= 0) %>%
    mutate(color_value = hcl(h = H,
                             c = C,
                             l = L)) %>%
    mutate(x = C * cos(H * pi/180),
           y = C * sin(H * pi/180)) %>%
    mutate(perpendicular_from_C_L = x * sin(-H_point * pi/180) + 
             y * cos(-H_point * pi/180),
           parallel_along_C_L = x * cos(-H_point * pi/180) - 
             y * sin(-H_point * pi/180)) %>%
    mutate(row_value = sample(row_number(), n()),
           col_value = ceiling(row_value / sqrt(n_color))) %>%
    mutate(row_value = (row_value %% sqrt(n_color)) + 1) %>%
    mutate(L_cut = as.character(
      cut(L, breaks = c(-Inf, seq(L_point - width, 
                                  L_point + width, 
                                  length.out = 7), 
                        Inf),
          labels = c(L_point - width, 
                     seq(L_point - (width * .8), 
                         L_point + (width * .8), 
                         length.out = 6), 
                     L_point + width))),
      C_cut = as.character(
        cut(C,
            breaks = c(-Inf, 
                       seq(C_point - width, 
                           C_point + width, 
                           length.out = 7), 
                       Inf),
            labels = c(C_point - width, 
                       seq(C_point - (width * .8), 
                           C_point + (width * .8), 
                           length.out = 6), 
                       C_point + width))),
      H_cut = as.character(
        cut((180 - abs(abs(H - H_point) - 180)) * 
              sign(180 - abs(H - H_point)) * 
              sign(H - H_point),
            breaks = c(-Inf, 
                       seq(0 - width, 
                           0 + width, 
                           length.out = 7), 
                       Inf),
            labels = c(H_point - width, 
                       seq(H_point - (width * .8), 
                           H_point + (width * .8), 
                           length.out = 6), 
                       H_point + width) %% 360)),
      perpendicular_from_C_L_cut = as.character(
        cut(perpendicular_from_C_L,
            breaks = c(-Inf, seq(0 - width, 
                                 0 + width, 
                                 length.out = 7), 
                       Inf),
            labels = c(0 - width, 
                       seq(0 - (width * .8), 
                           0 + (width * .8), 
                           length.out = 6), 
                       0 + width))),
      parallel_along_C_L_cut = as.character(
        cut(parallel_along_C_L,
            breaks = c(-Inf, 
                       seq(C_point - width, 
                           C_point + width, 
                           length.out = 7), 
                       Inf),
            labels = c(C_point - width, 
                       seq(C_point - (width * .8), 
                           C_point + (width * .8), 
                           length.out = 6), 
                       C_point + width)))) %>%
    mutate(L_cut = as.factor(as.numeric(L_cut)),
           C_cut = as.factor(as.numeric(C_cut)),
           H_cut = as.factor(as.numeric(H_cut)),
           perpendicular_from_C_L_cut = 
             as.factor(as.numeric(perpendicular_from_C_L_cut)),
           parallel_along_C_L_cut = 
             as.factor(as.numeric(parallel_along_C_L_cut))) %>%
    mutate(H_cut = fct_expand(H_cut, 
                              as.character(c(H_point - width, 
                                             seq(H_point - (width * .8), 
                                                 H_point + (width * .8), 
                                                 length.out = 6), 
                                             H_point + width) %% 360)),
           perpendicular_from_C_L_cut = 
             fct_expand(perpendicular_from_C_L_cut,
                        as.character(c(0 - width, 
                                       seq(0 - (width * .8), 
                                           0 + (width * .8), 
                                           length.out = 6), 
                                       0 + width)))) %>%
    mutate(H_cut = fct_relevel(H_cut, 
                               as.character(c(H_point - width, 
                                              seq(H_point - (width * .8), 
                                                  H_point + (width * .8), 
                                                  length.out = 6), 
                                              H_point + width) %% 360)),
           perpendicular_from_C_L_cut = 
             fct_relevel(perpendicular_from_C_L_cut,
                         as.character(c(0 - width, 
                                        seq(0 - (width * .8), 
                                            0 + (width * .8), 
                                            length.out = 6), 
                                        0 + width)))) %>%
    mutate(H_cut = fct_rev(H_cut),
           perpendicular_from_C_L_cut = fct_rev(perpendicular_from_C_L_cut))
}

xyl <- get_xyl_data(H_point, C_point, L_point, width, n_color)

graph_info <- function(H_point, C_point, L_point) {
  color_hex <- hcl(H_point, 
                   C_point,
                   L_point,
                   fixup = FALSE)
  
  ggplot() +
    geom_rect(aes(xmin = 0, xmax = 1,
                  ymin = 0, ymax = .5), col = color_hex, fill = color_hex) +
    geom_text(data = data.frame(x = 0,
                                y = seq(1.5, .75, -.25),
                                label = c(paste("HEX Value:", color_hex), 
                                          paste("H Value:", H_point),
                                          paste("C Value:", C_point),
                                          paste("L Value:", L_point))),
              aes(x, y, label = label), hjust = 0, size = 4) +
    coord_equal() +
    theme_void()
}

graph_sample <- function(color_points) {
  ggplot(data = color_points,
         aes(x = row_value,
             y = col_value,
             fill = color_value)) +
    geom_tile() +
    coord_equal() +
    scale_fill_identity() +
    theme_void()
}

p1 <- graph_info(H_point, C_point, L_point)
p2 <- graph_sample(xyl)
p1 + p2

# C L plane by H
graph_C_L_by_H <- function(color_points) {
  ggplot(data = color_points, aes(C, L, 
                                  col = color_value, fill = color_value)) +
    geom_point() +
    scale_color_identity() +
    scale_fill_identity() +
    facet_wrap(~ H_cut, nrow = 2) +
    coord_equal() 
}

get_C_L_plane_by_H <- function(color_points, H_point, width) {
  map_dfr(as.numeric(as.character(unique(color_points$H_cut))), 
          get_C_L_plane) %>%
    mutate(H_cut = as.factor(H)) %>%
    mutate(H_cut = fct_expand(H_cut, 
                              as.character(c(H_point - width, 
                                             seq(H_point - (width * .8), 
                                                 H_point + (width * .8), 
                                                 length.out = 6), 
                                             H_point + width) %% 360))) %>%
    mutate(H_cut = fct_relevel(H_cut, 
                               as.character(c(H_point - width, 
                                              seq(H_point - (width * .8), 
                                                  H_point + (width * .8), 
                                                  length.out = 6), 
                                              H_point + width) %% 360))) %>%
    mutate(H_cut = fct_rev(H_cut))
}
C_L_plane_by_H <- get_C_L_plane_by_H(xyl, H_point, width)

graph_C_L_plane_by_H <- function(C_L_plane, color_points) {
  ggplot() +
    geom_point(data = C_L_plane,
               aes(C, L, color = color_value, fill = color_value)) +
    geom_point(data = color_points,
               aes(C, L, color = "white", fill = "white")) +
    scale_x_continuous(labels = abs) +
    scale_color_identity() +
    scale_fill_identity() +
    facet_wrap(~ H_cut, nrow = 2) +
    coord_equal()
}
graph_C_L_plane_by_H(C_L_plane_by_H, xyl)
graph_C_L_by_H(xyl)

# H L curve
graph_H_L_by_C <- function(color_points, H_point) {
  ggplot(data = color_points, aes((H + (180 - H_point)) %% 360, L, 
                                  color = color_value, fill = color_value)) +
    geom_point() +
    scale_color_identity() +
    scale_fill_identity() +
    scale_x_reverse('H', # Like you're standing on the inside
                    labels = label_H_center(H_point = H_point)) +
    facet_wrap(~ C_cut, nrow = 2) +
    coord_equal()
}

get_H_L_curve_by_C <- function(color_points) {
  map_dfr(as.numeric(as.character(
    unique(color_points$C_cut))), get_H_L_curve) %>%
    mutate(C_cut = as.factor(C))
}
H_L_curve_by_C <- get_H_L_curve_by_C(xyl)

graph_H_L_curve_by_C <- function(H_L_curve_by_C, color_points, H_point) {
  ggplot() +
    geom_point(data = H_L_curve_by_C,
               aes((H + (180 - H_point)) %% 360, L, 
                   color = color_value, fill = color_value)) +
    geom_point(data = color_points,
               aes((H + (180 - H_point)) %% 360, L, 
                   color = "white", fill = "white")) +
    scale_color_identity() +
    scale_fill_identity() +
    scale_x_reverse('H', # Like you're standing on the inside
                    labels = label_H_center(H_point = H_point),
                    limits = c(360, 0)) +
    facet_wrap(~ C_cut, nrow = 2) +
    coord_equal()
}
graph_H_L_curve_by_C(H_L_curve_by_C, xyl, H_point)
graph_H_L_by_C(xyl, H_point)

# H C plane by L
graph_x_y_by_L <- function(color_points, H_point) {
  ggplot(data = color_points, aes(x, y, 
                                  col = color_value, fill = color_value)) +
    geom_abline(slope = c(tan(-67.5 * pi/180), 
                          tan(-45 * pi/180), 
                          tan(-22.5 * pi/180),
                          0, 100000,
                          tan(22.5 * pi/180), 
                          tan(45 * pi/180), 
                          tan(67.5 * pi/180)), 
                intercept = 0,
                color = "white") +
    geom_abline(slope = tan(H_point * pi/180), 
                intercept = 0,
                color = "black") +
    geom_point() +
    scale_color_identity() +
    scale_fill_identity() +
    facet_wrap(~ L_cut, nrow = 2) +
    coord_equal() +
    theme(axis.line=element_blank(), axis.text.x=element_blank(),
          axis.text.y=element_blank(), axis.ticks=element_blank(),
          axis.title.x=element_blank(), axis.title.y=element_blank(),
          panel.grid.major=element_blank(), panel.grid.minor=element_blank())
}

get_H_C_plane_by_L <- function(color_points) {
  map_dfr(as.numeric(
    as.character(unique(color_points$L_cut))), get_H_C_plane) %>%
    mutate(L_cut = as.factor(L))
}
H_C_plane_by_L <- get_H_C_plane_by_L(xyl)

graph_H_C_plane_by_L <- function(H_C_plane_by_L, color_points) {
  ggplot() +
    geom_point(data = H_C_plane_by_L,
               aes(H, C, color = color_value, fill = color_value)) +
    geom_point(data = color_points,
               aes(H , C, color = "white", fill = "white")) +
    scale_color_identity() +
    scale_fill_identity() +
    scale_x_continuous(breaks = seq(45, 360, 45),
                       minor_breaks = seq(0, 315, 45) + 45/2,
                       labels = c('45', '90', '135', '180', 
                                  '225', '270', '315', '0|360')) +
    scale_y_continuous(limits = c(0, 180)) +
    facet_wrap(~ L_cut, nrow = 2) +
    coord_polar(start = 270 * pi / 180,
                direction = -1)
}
graph_H_C_plane_by_L(H_C_plane_by_L, xyl)
graph_x_y_by_L(xyl, H_point)

# C tangent plane
label_perpendicular_from_C_L_cut<- function(x) {
  as.character(abs(as.numeric(x)))
}

graph_parallel_perpendicular_by_L <- function(color_points) {
  ggplot(data = color_points, 
         aes(x = parallel_along_C_L,
             y = perpendicular_from_C_L,
             color = color_value)) +
    geom_point() +
    scale_color_identity() +
    scale_y_continuous("Distance Perpendicular to C-L Plane", # similar to H
                       labels = abs) +
    labs(x = "Distance Parallel to C-L Plane") + # similar to C
    facet_wrap(~ L_cut, nrow = 2) +
    coord_equal()
}
graph_parallel_perpendicular_by_L(xyl)

graph_perpendicular_L_by_parallel <- function(color_points) {
  ggplot(data = color_points, 
         aes(x = perpendicular_from_C_L,
             y = L,
             color = color_value)) +
    geom_point() +
    scale_color_identity() +
    scale_x_continuous("Distance Perpendicular to C-L Plane",
                       labels = abs) +
    scale_y_continuous(labels = abs) +
    facet_wrap(~ parallel_along_C_L_cut, nrow = 2) +
    coord_equal()
}
graph_perpendicular_L_by_parallel(xyl)

graph_parallel_L_by_perpendicular <- function(color_points) {
  ggplot(data = color_points, 
         aes(x = parallel_along_C_L,
             y = L,
             color = color_value)) +
    geom_point() +
    scale_color_identity() +
    labs(x = "Distance Parallel to C-L Plane") +
    facet_wrap(~ perpendicular_from_C_L_cut, nrow = 2,
               labeller = as_labeller(label_perpendicular_from_C_L_cut)) +
    coord_equal()
}
graph_parallel_L_by_perpendicular(xyl)

xyL: Info

xyL: Info

Above, we see the basic information about the base color and the points sampled around the sphere’s perimeter (width is 15 units). Below, we see the sphere cut up by C-L Planes at different Hue values. We see that as we move across different values for our Hue, we slice through the sphere to get different shapes. For example, on the right image, the ends are moved left slightly, and the doughnuts in the middle values tend to have a little more width on the right side. The distortion occurs because our cuts are fanning across the sphere at an angle, not moving along in a straight line. So, one side gets a little more of the perimeter to plot.

xyL: C-L Plane by H cuts

xyL: C-L Plane by H cuts

xyL: C-L by H cuts

xyL: C-L by H cuts

The next set shows the H-L Curve by different Chroma values. Here, we might expect to see circles, but instead, we get ovals. These shapes occur because the ring made by moving Hue through the sphere cuts along a curve, then it’s flattened for these facets. Also, technically the facets are a little off because the width of the graph should be increasing as Chroma increases. So even though they show the same Hue values, the arc length of the circle should be getting longer. Therefore the graphs should be getting wider.

xyL: H-L Curve by C cuts

xyL: H-L Curve by C cuts

xyL: H-L by C cuts

xyL: H-L by C cuts

In the next set, we finally get our nice circles. As we slice through our sphere for different Luminance values, we get our circles in the H-C Planes.

xyL: H-C Plane by L cuts

xyL: H-C Plane by L cuts

xyL: H-C by L cuts

xyL: H-C by L cuts

The following three images also have circles because we’re cutting our sphere along straight lines equal distances from each other. The first facet set looks at the sphere from up above as we cut through different Luminance values. This image is the same as the right side in the previous group, but each sub-image gets rotated, so the Hue angle moves flat left to right. The x-axis is the distance along the C-L Plane that cuts the sphere in half. This distance isn’t equivalent to Chroma because that moves at an angle determined by Hue, but it is related. The values are the Chroma values on the C-L Plane shifted in a straight line from the C-L Plane. The y-axis is the distance from the C-L Plane in either direction.

xyL: Parallel-Perpendicular Distances by L cuts

xyL: Parallel-Perpendicular Distances by L cuts

The next two images both have the Luminance values along the y-axis but cut the sphere differently. The left side slices the sphere moving out from the center of the HCL color space along the Chroma values. Here we see the nice circles instead of ovals for the images of the H-L cut by C. The right side cuts are made parallel to the C-L Plane and move along a straight line. We’re also getting circles that aren’t a little lop-sided, unlike the C-L cut by H ones.

xyL: Perpendicular Distance-L by Parallel Distance cuts

xyL: Perpendicular Distance-L by Parallel Distance cuts

xyL: Parallel Distance-L by Perpendicular Distance cuts

xyL: Parallel Distance-L by Perpendicular Distance cuts


HCL Perimeter


We’ll move away from the base color along Hue, Chroma, and Luminance for the second main section. So we’ll curve around when changing Hue values.

[HCL Perimeter Code]
##------------------
# H, C, L, perimeter
##------------------
get_hcl_data <- function(H_point, C_point, L_point, width, n_color) {
 data.frame(theta = 2 * pi * seq(0, n_color - 1) / ((1 + sqrt(5)) / 2),
                  phi = acos(1 - 2 * (seq(0, n_color - 1) + .5) / n_color)) %>%
  mutate(H = cos(theta) * sin(phi),
         C = sin(theta) * sin(phi),
         L = cos(phi)) %>%
  mutate(H = H * width + H_point,
         C = C * width + C_point,
         L = L * width + L_point) %>%
  mutate(H = H %% 360) %>% # not really needed except for graphs
  filter(L >= 0 & L <= 100 & C >= 0) %>%
  mutate(color_value = hcl(h = H,
                           c = C,
                           l = L)) %>%
  mutate(x = C * cos(H * pi/180),
         y = C * sin(H * pi/180)) %>%
  mutate(perpendicular_from_C_L = x * sin(-H_point * pi/180) + 
           y * cos(-H_point * pi/180), # not a C value since we didn't rotate by H
         parallel_along_C_L = x * cos(-H_point * pi/180) - 
           y * sin(-H_point * pi/180)) %>%
  mutate(row_value = sample(row_number(), n()),
         col_value = ceiling(row_value / sqrt(n_color))) %>%
  mutate(row_value = (row_value %% sqrt(n_color)) + 1) %>%
  mutate(L_cut = as.character(
    cut(L, breaks = c(-Inf, seq(L_point - width, 
                                L_point + width, 
                                length.out = 7), 
                      Inf),
        labels = c(L_point - width, 
                   seq(L_point - (width * .8), 
                       L_point + (width * .8), 
                       length.out = 6), 
                   L_point + width))),
         C_cut = as.character(
           cut(C,
               breaks = c(-Inf, 
                          seq(C_point - width, 
                              C_point + width, 
                              length.out = 7), 
                          Inf),
               labels = c(C_point - width, 
                          seq(C_point - (width * .8), 
                              C_point + (width * .8), 
                              length.out = 6), 
                          C_point + width))),
         H_cut = as.character(
           cut((180 - abs(abs(H - H_point) - 180)) * 
                 sign(180 - abs(H - H_point)) * 
                 sign(H - H_point),
               breaks = c(-Inf, 
                          seq(0 - width, 
                              0 + width, 
                              length.out = 7), 
                          Inf),
               labels = c(H_point - width, 
                          seq(H_point - (width * .8), 
                              H_point + (width * .8), 
                              length.out = 6), 
                          H_point + width) %% 360)),
         perpendicular_from_C_L_cut = as.character(
           cut(perpendicular_from_C_L,
               breaks = c(-Inf, seq(0 - width, 
                                    0 + width, 
                                    length.out = 7), 
                          Inf),
               labels = c(0 - width, 
                          seq(0 - (width * .8), 
                              0 + (width * .8), 
                              length.out = 6), 
                          0 + width))),
         parallel_along_C_L_cut = as.character(
           cut(parallel_along_C_L,
               breaks = c(-Inf, 
                          seq(C_point - width, 
                              C_point + width, 
                              length.out = 7), 
                          Inf),
               labels = c(C_point - width, 
                          seq(C_point - (width * .8), 
                              C_point + (width * .8), 
                              length.out = 6), 
                          C_point + width)))) %>%
  mutate(L_cut = as.factor(as.numeric(L_cut)),
         C_cut = as.factor(as.numeric(C_cut)),
         H_cut = as.factor(as.numeric(H_cut)),
         perpendicular_from_C_L_cut = 
           as.factor(as.numeric(perpendicular_from_C_L_cut)),
         parallel_along_C_L_cut = 
           as.factor(as.numeric(parallel_along_C_L_cut))) %>%
  mutate(H_cut = fct_expand(H_cut, 
                            as.character(c(H_point - width, 
                                           seq(H_point - (width * .8), 
                                               H_point + (width * .8), 
                                               length.out = 6), 
                                           H_point + width) %% 360)),
         perpendicular_from_C_L_cut = 
           fct_expand(perpendicular_from_C_L_cut,
                      as.character(c(0 - width, 
                                     seq(0 - (width * .8), 
                                         0 + (width * .8), 
                                         length.out = 6), 
                                     0 + width)))) %>%
  mutate(H_cut = fct_relevel(H_cut, 
                             as.character(c(H_point - width, 
                                            seq(H_point - (width * .8), 
                                                H_point + (width * .8), 
                                                length.out = 6), 
                                            H_point + width) %% 360)),
         perpendicular_from_C_L_cut = 
           fct_relevel(perpendicular_from_C_L_cut,
                      as.character(c(0 - width, 
                                     seq(0 - (width * .8), 
                                         0 + (width * .8), 
                                         length.out = 6), 
                                     0 + width)))) %>%
  mutate(H_cut = fct_rev(H_cut),
         perpendicular_from_C_L_cut = fct_rev(perpendicular_from_C_L_cut))
}

hcl <- get_hcl_data(H_point, C_point, L_point, width, n_color)

p1 <- graph_info(H_point, C_point, L_point)
p2 <- graph_sample(hcl)
p1 + p2

# C L plane by H
C_L_plane_by_H <- get_C_L_plane_by_H(hcl, H_point, width)
graph_C_L_by_H(hcl)
graph_C_L_plane_by_H(C_L_plane_by_H, hcl)

# H L curve
H_L_curve_by_C <- get_H_L_curve_by_C(hcl)
graph_H_L_by_C(hcl, H_point)
graph_H_L_curve_by_C(H_L_curve_by_C, hcl, H_point)

# H C plane by L
H_C_plane_by_L <- get_H_C_plane_by_L(hcl)
graph_x_y_by_L(hcl, H_point)
graph_H_C_plane_by_L(H_C_plane_by_L, hcl)

# C tangent plane
graph_parallel_perpendicular_by_L(hcl)

graph_perpendicular_L_by_parallel(hcl)

graph_parallel_L_by_perpendicular(hcl)

HCL: Info

HCL: Info

This first set of graphs plots the C-L plane split by Hue values with both the perimeter and where it fits in the color space. We see these nice circles as the perimeter is cut moving along Hue values.

HCL: C-L Plane by H cuts

HCL: C-L Plane by H cuts

HCL: C-L by H cuts

HCL: C-L by H cuts

The next set shows H-L Curve split by different Chroma values. We’re also getting nice circles, unlike the xyL section.

HCL: H-L Curve by C cuts

HCL: H-L Curve by C cuts

HCL: H-L by C cuts

HCL: H-L by C cuts

The following graph breaks the trend of nice circles. Here we see teardrop shapes instead. The next few images pull this apart a little more.

HCL: H-C Plane by L cuts

HCL: H-C Plane by L cuts

HCL: H-C by L cuts

HCL: H-C by L cuts

Here we see the teardrop shapes in more detail. Because we created our “sphere” following polar coordinates, it doesn’t turn out as we might expect. This shape’s height is a function of what a sphere should be but augmented by the distance between Hue values increasing when Chroma increases. Notice that the center Chroma value is at 26. Not in the middle of the circular part, like at 30-ish.

HCL: Parallel-Perpendicular Distances by L cuts

HCL: Parallel-Perpendicular Distances by L cuts

We also get a squished version for the image on the left. There are fewer cuts because the shape doesn’t reach out as far as the image on the right.

HCL: Perpendicular Distance-L by Parallel Distance cuts

HCL: Perpendicular Distance-L by Parallel Distance cuts

HCL: Parallel Distance-L by Perpendicular Distance cuts

HCL: Parallel Distance-L by Perpendicular Distance cuts

It is a little unwieldy to compare the different main sections apart, so we’ll add them together in the next one.


Compare Perimeters


For the following images, the two sets we previously created are stuck together and then graphed to highlight the differences well.

[Compare Code]
##-------
# Compare
##-------
compare <- rbind(xyl[, c('H', 'C', 'L', 'x', 'y', 
                         'H_cut', 'C_cut', 'L_cut', 
                         'perpendicular_from_C_L', 'parallel_along_C_L', 
                         'perpendicular_from_C_L_cut', 'parallel_along_C_L_cut',
                         'row_value', 'col_value', 'color_value')],
                 hcl[, c('H', 'C', 'L', 'x', 'y', 
                         'H_cut', 'C_cut', 'L_cut', 
                         'perpendicular_from_C_L', 'parallel_along_C_L', 
                         'perpendicular_from_C_L_cut', 'parallel_along_C_L_cut',
                         'row_value', 'col_value', 'color_value')]) %>%
  mutate(setting = c(rep('XYL', n_color), rep('HCL', n_color))) 

# C L plane
graph_C_L_by_H_comparison <- function(color_points) {
  ggplot(data = color_points, aes(C, L, col = setting)) +
    geom_point(alpha = .5) +
    facet_wrap(~ H_cut, nrow = 2) +
    coord_equal() +
    scale_color_discrete("") +
    theme(legend.position = "bottom")
}
graph_C_L_by_H_comparison(compare)

# H L curve
graph_H_L_by_C_comparison <- function(color_points, H_point) {
  ggplot(data = color_points, 
         aes((H + (180 - H_point)) %% 360, L, color = setting)) +
    geom_point(alpha = .5) +
    scale_x_reverse('H',
                    labels = label_H_center(H_point = H_point)) +
    facet_wrap(~ C_cut, nrow = 2) +
    coord_equal() +
    scale_color_discrete("") +
    theme(legend.position = "bottom")
}
graph_H_L_by_C_comparison(compare, H_point)

# H C plane
graph_x_y_by_L_comparison <- function(color_points, H_point) {
  ggplot(data = color_points, aes(x, y, col = setting)) +
    geom_abline(slope = c(tan(-67.5 * pi/180), 
                          tan(-45 * pi/180), 
                          tan(-22.5 * pi/180),
                          0, 100000,
                          tan(22.5 * pi/180), 
                          tan(45 * pi/180), 
                          tan(67.5 * pi/180)), 
                intercept = 0,
                color = "white") +
    geom_abline(slope = tan(H_point * pi/180), 
                intercept = 0,
                color = "black") +
    geom_point(alpha = .5) +
    facet_wrap(~ L_cut, nrow = 2) +
    coord_equal() +
    scale_color_discrete("") +
    theme(legend.position = "bottom",
          axis.line=element_blank(), axis.text.x=element_blank(),
          axis.text.y=element_blank(), axis.ticks=element_blank(),
          axis.title.x=element_blank(), axis.title.y=element_blank(),
          panel.grid.major=element_blank(), panel.grid.minor=element_blank())
}
graph_x_y_by_L_comparison(compare, H_point)

# C tangent plane
graph_parallel_perpendicular_by_L_comparison <- function(color_points) {
  ggplot(data = color_points, 
         aes(x = parallel_along_C_L,
             y = perpendicular_from_C_L,
             color = setting)) +
    geom_point(alpha = .5) +
    scale_y_continuous("Distance Perpendicular to C-L Plane",
                       labels = abs) +
    labs(x = "Distance Parallel to C-L Plane") +
    facet_wrap(~ L_cut, nrow = 2) +
    coord_equal() +
    scale_color_discrete("") +
    theme(legend.position = "bottom")
}
graph_parallel_perpendicular_by_L_comparison(compare)

graph_perpendicular_L_by_parallel_comparison <- function(color_points) {
  ggplot(data = color_points, 
         aes(x = perpendicular_from_C_L,
             y = L,
             color = setting)) +
    geom_point(alpha = .5) +
    scale_x_continuous("Distance Perpendicular to C-L Plane", 
            labels = abs) +
    scale_y_continuous(labels = abs) +
    facet_wrap(~ parallel_along_C_L_cut, nrow = 2) +
    coord_equal() +
    scale_color_discrete("") +
    theme(legend.position = "bottom")
}
graph_perpendicular_L_by_parallel_comparison(compare)

graph_parallel_L_by_perpendicular_comparison <- function(color_points) {
  ggplot(data = color_points, 
         aes(x = parallel_along_C_L,
             y = L,
             color = setting)) +
    geom_point(alpha = .5) +
    labs(x = "Distance Parallel to C-L Plane") +
    facet_wrap(~ perpendicular_from_C_L_cut, nrow = 2,
               labeller = as_labeller(label_perpendicular_from_C_L_cut)) +
    coord_equal() +
    scale_color_discrete("") +
    theme(legend.position = "bottom")
}
graph_parallel_L_by_perpendicular_comparison(compare)

compare <- compare %>%
  group_by(setting) %>%
  arrange((180 - abs(abs(H - H_point) - 180)) * 
            sign(180 - abs(H - H_point)) * sign(H - H_point)) %>%
  mutate(col_value = ceiling(row_number() / sqrt(n_color))) %>%
  arrange(col_value, L) %>%
  group_by(setting, col_value) %>%
  mutate(row_value = row_number())

ggplot(data = compare,
       aes(x = col_value,
           y = row_value,
           fill = color_value)) +
  geom_tile() +
  coord_equal() +
  scale_fill_identity() +
  theme_void() +
  facet_grid(~setting)

compare <- compare %>%
  group_by(setting) %>%
  arrange((180 - abs(abs(H - H_point) - 180)) * 
            sign(180 - abs(H - H_point)) * sign(H - H_point)) %>%
  mutate(col_value = ceiling(row_number() / sqrt(n_color))) %>%
  arrange(col_value, C) %>%
  group_by(setting, col_value) %>%
  mutate(row_value = row_number())

ggplot(data = compare,
       aes(x = col_value,
           y = row_value,
           fill = color_value)) +
  geom_tile() +
  coord_equal() +
  scale_fill_identity() +
  theme_void() +
  facet_grid(~setting)

This section will have graphs with points from the xyL function in blue and HCL in orange. In the left image, we see the lop-sidedness of the xyL points with the circles of the HCL ones. Also, we can easily see that the XYL points stretch out to farther Hue values. Then we have the ovals for xyL and circles for HCL.

Comparison: C-L by L cuts

Comparison: C-L by L cuts

Comparison: H-L by C cuts

Comparison: H-L by C cuts

Then we can see the circles for xyL and teardrops for HCL.

Comparison: H-C by L cuts

Comparison: H-C by L cuts

Comparison: Parallel-Perpendicular by L cuts

Comparison: Parallel-Perpendicular by L cuts

Finally, we get a comparison that confirms the HCL function results in squeezing the perimeter, not stretching it vertically.

Comparison: Perpendicular-L by Parallel cuts

Comparison: Perpendicular-L by Parallel cuts

Comparison: Parallel-L by Perpendicular cuts

Comparison: Parallel-L by Perpendicular cuts

Graphing the perimeters is nice to see what’s going on mathematically in the space, but the main result is in the end colors. So the last images of this main section show the direct colors of the samples.

The first comparison has the samples sorted by Hue for the columns, then within each column, sorted by Luminance. I can’t tell a huge difference, but the Hue values for xyL seem to stretch slightly more than HCL, and HCL has a little more gray.

Comparison: Samples H by L

Comparison: Samples H by L

The second comparison has the samples sorted by Hue (like the first comparison), then sorted by Chroma. To me, this looks very different for having the same colors in the same columns. The HCL square is a little grayer at the bottom.

Comparison: Samples H by C

Comparison: Samples H by C

For the final two main sections, we’ll change only the Chroma value. First, we’ll move everything closer to the center.


Lower Chroma Value


We’ll start by changing the base Chroma value from 26 to 16. Everything else is the same.

[Lower Chroma Value Code]
## Try lower value of C ----
C_point <- 16

xyl <- get_xyl_data(H_point, C_point, L_point, width, n_color)

hcl <- get_hcl_data(H_point, C_point, L_point, width, n_color)

compare <- rbind(xyl[, c('H', 'C', 'L', 'x', 'y', 
                         'H_cut', 'C_cut', 'L_cut', 
                         'perpendicular_from_C_L', 'parallel_along_C_L', 
                         'perpendicular_from_C_L_cut', 'parallel_along_C_L_cut',
                         'row_value', 'col_value', 'color_value')],
                 hcl[, c('H', 'C', 'L', 'x', 'y', 
                         'H_cut', 'C_cut', 'L_cut', 
                         'perpendicular_from_C_L', 'parallel_along_C_L', 
                         'perpendicular_from_C_L_cut', 'parallel_along_C_L_cut',
                         'row_value', 'col_value', 'color_value')]) %>%
  mutate(setting = c(rep('XYL', n_color), rep('HCL', n_color))) 

# C L plane
graph_C_L_by_H_comparison(compare)

# H L curve
graph_H_L_by_C_comparison(compare, H_point)

# H C plane
graph_x_y_by_L_comparison(compare, H_point)

# C tangent plane
graph_parallel_perpendicular_by_L_comparison(compare)

graph_perpendicular_L_by_parallel_comparison(compare)

graph_parallel_L_by_perpendicular_comparison(compare)

compare <- compare %>%
  group_by(setting) %>%
  arrange((180 - abs(abs(H - H_point) - 180)) * 
            sign(180 - abs(H - H_point)) * sign(H - H_point)) %>%
  mutate(col_value = ceiling(row_number() / sqrt(n_color))) %>%
  arrange(col_value, L) %>%
  group_by(setting, col_value) %>%
  mutate(row_value = row_number())

ggplot(data = compare,
       aes(x = col_value,
           y = row_value,
           fill = color_value)) +
  geom_tile() +
  coord_equal() +
  scale_fill_identity() +
  theme_void() +
  facet_grid(~setting)

compare <- compare %>%
  group_by(setting) %>%
  arrange((180 - abs(abs(H - H_point) - 180)) * 
            sign(180 - abs(H - H_point)) * sign(H - H_point)) %>%
  mutate(col_value = ceiling(row_number() / sqrt(n_color))) %>%
  arrange(col_value, C) %>%
  group_by(setting, col_value) %>%
  mutate(row_value = row_number())

ggplot(data = compare,
       aes(x = col_value,
           y = row_value,
           fill = color_value)) +
  geom_tile() +
  coord_equal() +
  scale_fill_identity() +
  theme_void() +
  facet_grid(~setting)


From these graphs, we can see everything is exaggerated. For example, the first two images have similar circles for HCL, but xyL is breaking apart and getting stretched.

Lower C: C-L by L cuts

Lower C: C-L by L cuts

Lower C: H-L by C cuts

Lower C: H-L by C cuts

Then we see the circles for xyL, but HCL has a very tight teardrop shape.

Lower C: H-C by L cuts

Lower C: H-C by L cuts

Lower C: Parallel-Perpendicular by L cuts

Lower C: Parallel-Perpendicular by L cuts

The same trend continues with the exaggeration for HCL.

Lower C: Perpendicular-L by Parallel cuts

Lower C: Perpendicular-L by Parallel cuts

Lower C: Parallel-L by Perpendicular cuts

Lower C: Parallel-L by Perpendicular cuts

Finally, we can compare the sampled colors directly.

Lower C: Samples H by L

Lower C: Samples H by L

Lower C: Samples H by C

Lower C: Samples H by C


Higher Chroma Value


For the last main section, we’ll set Chroma to 75.

[Higher Chroma Value Code]
## Try higher value of C ----
C_point <- 75
 
xyl <- get_xyl_data(H_point, C_point, L_point, width, n_color)

hcl <- get_hcl_data(H_point, C_point, L_point, width, n_color)

compare <- rbind(xyl[, c('H', 'C', 'L', 'x', 'y', 
                         'H_cut', 'C_cut', 'L_cut', 
                         'perpendicular_from_C_L', 'parallel_along_C_L', 
                         'perpendicular_from_C_L_cut', 'parallel_along_C_L_cut',
                         'row_value', 'col_value', 'color_value')],
                 hcl[, c('H', 'C', 'L', 'x', 'y', 
                         'H_cut', 'C_cut', 'L_cut', 
                         'perpendicular_from_C_L', 'parallel_along_C_L', 
                         'perpendicular_from_C_L_cut', 'parallel_along_C_L_cut',
                         'row_value', 'col_value', 'color_value')]) %>%
  mutate(setting = c(rep('XYL', n_color), rep('HCL', n_color))) 

# C L plane
graph_C_L_by_H_comparison(compare)

# H L curve
graph_H_L_by_C_comparison(compare, H_point)

# H C plane
graph_x_y_by_L_comparison(compare, H_point)

# C tangent plane
graph_parallel_perpendicular_by_L_comparison(compare)

graph_perpendicular_L_by_parallel_comparison(compare)

graph_parallel_L_by_perpendicular_comparison(compare)

compare <- compare %>%
  group_by(setting) %>%
  arrange((180 - abs(abs(H - H_point) - 180)) * 
            sign(180 - abs(H - H_point)) * sign(H - H_point)) %>%
  mutate(col_value = ceiling(row_number() / sqrt(n_color))) %>%
  arrange(col_value, L) %>%
  group_by(setting, col_value) %>%
  mutate(row_value = row_number())

ggplot(data = compare,
       aes(x = col_value,
           y = row_value,
           fill = color_value)) +
  geom_tile() +
  coord_equal() +
  scale_fill_identity() +
  theme_void() +
  facet_grid(~setting)

compare <- compare %>%
  group_by(setting) %>%
  arrange((180 - abs(abs(H - H_point) - 180)) * 
            sign(180 - abs(H - H_point)) * sign(H - H_point)) %>%
  mutate(col_value = ceiling(row_number() / sqrt(n_color))) %>%
  arrange(col_value, C) %>%
  group_by(setting, col_value) %>%
  mutate(row_value = row_number())

ggplot(data = compare,
       aes(x = col_value,
           y = row_value,
           fill = color_value)) +
  geom_tile() +
  coord_equal() +
  scale_fill_identity() +
  theme_void() +
  facet_grid(~setting)


This time, the results have a different shape. Now, HCL stretches out farther than xyL (but still in a circle).

Higher C: C-L by L cuts

Higher C: C-L by L cuts

Higher C: H-L by C cuts

Higher C: H-L by C cuts

The graphs from above show the stretching well. Instead of the teardrop shape, we now have ovals. At some point in increasing the Chroma value, the HCL function surpasses xyL.

Higher C: H-C by L cuts

Higher C: H-C by L cuts

Higher C: Parallel-Perpendicular by L cuts

Higher C: Parallel-Perpendicular by L cuts

We see the same trend continue here.

Higher C: Perpendicular-L by Parallel cuts

Higher C: Perpendicular-L by Parallel cuts

Higher C: Parallel-L by Perpendicular cuts

Higher C: Parallel-L by Perpendicular cuts

And finally, we can compare the examples again.

Higher C: Samples H by L

Higher C: Samples H by L

Higher C: Samples H by C

Higher C: Samples H by C

I think at the end of the post, I’m supposed to say one is better than the other, but I think they’re just different. It really depends on what you’re looking for in selecting colors. Looking at the colors is the best way to do that, but the other graphs help explain what is happening and determine the next steps.


  1. If you understand the difference between polar and Cartesian coordinates pretty well, this blog post will be obvious to you. But I’m bad at math and needed to see a lot of graphs to understand what was happening. So I figured I’d throw this in a blog post.↩︎