03b. Code for visual encodings notes

Author

Camille Seaberry

Modified

September 8, 2026

These are the charts from the notes on visual encodings, but focused on the code. Otherwise, as always, the code for this whole site is on GitHub.

library(ggplot2)
library(dplyr)

# pull a qualitative palette from carto colors
qual_pal <- rcartocolor::carto_pal(name = "Bold")
# colorspace::swatchplot(qual_pal)
gender_pal <- qual_pal[c(3, 7)]

# pull a diverging palette but make center value gray
div_pal <- rcartocolor::carto_pal(n = 3, name = "Earth")
div_pal[2] <- "gray40"

sex_x_edu <- justviz::wages |>
    filter(dimension %in% c("by_edu", "by_sex_x_edu"), status == "full_time") |>
    select(sex, edu, earn_q20, earn_q50, earn_q80)

sex_x_edu
sex edu earn_q20 earn_q50 earn_q80
total no_diploma 27868 43234 70000
total high_school_diploma 33227 52520 85018
total some_college 40000 64311 104451
total bachelors 54655 91091 145771
total graduate_degree 72873 116000 181895
men no_diploma 30881 48582 75145
men high_school_diploma 36436 58000 92644
men some_college 42874 72057 115382
men bachelors 60727 102938 162199
men graduate_degree 81100 134000 202749
women no_diploma 21617 35264 57928
women high_school_diploma 30700 46322 72873
women some_college 36436 56616 92685
women bachelors 50000 80160 126000
women graduate_degree 68015 102938 157891

Palettes are just vectors of colors, usually hex codes. For example, gender_pal is just #3969AC, #E68310. There are functions to view them: you already have colorspace installed, so call colorspace::swatchplot(gender_pal) to view the colors.

colorspace::swatchplot(qual_pal)

Scales only, no geometries: inside ggplot’s aes (aesthestics) call, assign education to the x scale and median earnings to the y scale. Let’s call this chart 1.

sex_x_edu |>
    filter(sex == "total") |>
    ggplot(aes(x = edu, y = earn_q50))

Same as chart 1, but add a point geometry (see ?geom_point)

sex_x_edu |>
    filter(sex == "total") |>
    ggplot(aes(x = edu, y = earn_q50)) +
    geom_point(size = 6)

Same as chart 1, but instead add a column geometry (see ?geom_col). Call this chart 2.

sex_x_edu |>
    filter(sex == "total") |>
    ggplot(aes(x = edu, y = earn_q50)) +
    geom_col(width = 0.8)

Modify chart 2 to also map sex onto the fill aesthetic. Note that there are independent aesthetics for color and fill, and most larger shapes like columns will default to a fill but not a color (color would provide an outline), while most smaller shapes like points and lines have a color only. (If you look at the standard codes for point shapes, some have both a fill and an outline color; this is very useful in some cases but we won’t get into it now.) Tl;dr if you try to set what you think of as color and don’t see it change, it might actually be the fill.

Add a manual fill scale so we can use the palette we pulled out earlier.

sex_x_edu |>
    filter(sex != "total") |>
    ggplot(aes(x = edu, y = earn_q50, fill = sex)) +
    geom_col(width = 0.8) +
    scale_fill_manual(values = gender_pal)

Bars shouldn’t be stacked, so set the position to dodge them, i.e. put them next to each other. Each bar is a combination of x/education and fill/sex. position_dodge2 puts a nice lil gap between the bars at each x value, whereas position_dodge will have them smooshed together.

sex_x_edu |>
    filter(sex != "total") |>
    ggplot(aes(x = edu, y = earn_q50, fill = sex)) +
    geom_col(width = 0.8, position = position_dodge2()) +
    scale_fill_manual(values = gender_pal)

Instead of medians by sex, now show values at each of 3 percentiles. 20th, 50th, and 80th percentiles each have their own column in the original dataset, but if we want to assign percentile to a fill, we need a single variable that has those percentile breaks and another single variable that has values. This is called tidy data, coined by the main dev of ggplot. The tidyr package helps get data into the correct shape for the tidy data/grammar of graphics paradigm.

Just the data tidying step:

ptiles_tidy <- sex_x_edu |>
    filter(sex == "total") |>
    tidyr::pivot_longer(
        -sex:-edu,
        names_to = "percentile",
        values_to = "earnings",
        names_ptypes = list(percentile = factor())
    )
ptiles_tidy
sex edu percentile earnings
total no_diploma earn_q20 27868
total no_diploma earn_q50 43234
total no_diploma earn_q80 70000
total high_school_diploma earn_q20 33227
total high_school_diploma earn_q50 52520
total high_school_diploma earn_q80 85018
total some_college earn_q20 40000
total some_college earn_q50 64311
total some_college earn_q80 104451
total bachelors earn_q20 54655
total bachelors earn_q50 91091
total bachelors earn_q80 145771
total graduate_degree earn_q20 72873
total graduate_degree earn_q50 116000
total graduate_degree earn_q80 181895

Now plot that data with earnings on y and percentile on fill. Call it chart 3

ptiles_tidy |>
    ggplot(aes(x = edu, y = earnings, fill = percentile)) +
    geom_col(width = 0.8, position = position_dodge2()) +
    scale_fill_manual(values = div_pal)

Chart 3 but with points instead of bars. Note that this requires switching from fill to color as our aesthetic.

ptiles_tidy |>
    ggplot(aes(x = edu, y = earnings, color = percentile)) +
    geom_point(size = 6) +
    scale_color_manual(values = div_pal)

Add a path geometry to join the points within each education level and across percentiles. Put it before the point layer so points are on top.

ptiles_tidy |>
    ggplot(aes(x = edu, y = earnings, color = percentile)) +
    geom_path(color = "gray80", linewidth = 3) +
    geom_point(size = 6) +
    scale_color_manual(values = div_pal)

Add a variable for percentile type, whether it’s 50th percentile (median) or one of the others (we’ll call them endpoints, I don’t have a better name). This gives us a column to assign more aesthetics to so medians stand out from 20th & 80th percentiles and have more visual importance.

Now assign point size to percentile type (note that you can do an aes call within any geom; since it only affects points I’ve put it there). Add a manual size scale.

ptiles_endpts <- ptiles_tidy |>
    mutate(
        ptile_type = ifelse(percentile == "earn_q50", "median", "endpoint")
    )

ptiles_endpts
sex edu percentile earnings ptile_type
total no_diploma earn_q20 27868 endpoint
total no_diploma earn_q50 43234 median
total no_diploma earn_q80 70000 endpoint
total high_school_diploma earn_q20 33227 endpoint
total high_school_diploma earn_q50 52520 median
total high_school_diploma earn_q80 85018 endpoint
total some_college earn_q20 40000 endpoint
total some_college earn_q50 64311 median
total some_college earn_q80 104451 endpoint
total bachelors earn_q20 54655 endpoint
total bachelors earn_q50 91091 median
total bachelors earn_q80 145771 endpoint
total graduate_degree earn_q20 72873 endpoint
total graduate_degree earn_q50 116000 median
total graduate_degree earn_q80 181895 endpoint
ptiles_endpts |>
    ggplot(aes(
        x = edu,
        y = earnings,
        color = percentile
    )) +
    geom_path(color = "gray80", linewidth = 3) +
    geom_point(aes(size = ptile_type)) +
    scale_color_manual(values = div_pal) +
    scale_size_manual(values = c(median = 6, endpoint = 3.5))

Add a third aesthetic to the points: percentile types should get different shapes as well. This requires a group aesthetic to make clear how the paths should be grouped. Look up the shape codes to find IDs of shapes you want. Some of these shapes are filled, so set both fill and color as the same scale (try taking out the aesthetics argument of the color scale). Add a manual shape scale.

ptiles_endpts |>
    ggplot(aes(
        x = edu,
        y = earnings,
        color = percentile,
        fill = percentile,
        group = edu
    )) +
    geom_path(color = "gray80", linewidth = 3) +
    geom_point(aes(size = ptile_type, shape = percentile)) +
    scale_color_manual(values = div_pal, aesthetics = c("color", "fill")) +
    scale_size_manual(values = c(median = 6.5, endpoint = 3.5)) +
    scale_shape_manual(values = c(earn_q20 = 25, earn_q80 = 24, earn_q50 = 19))

Last, clean it up. Walk through this code line by line to see the effects of the changes.

# sentence case + line wrapping
wrap_lbls <- function(x) {
    wrapper <- scales::label_wrap(width = 12)
    x <- snakecase::to_sentence_case(x)
    wrapper(x)
}

ptiles_endpts |>
    mutate(
        percentile = forcats::fct_recode(
            percentile,
            "20th pct" = "earn_q20",
            Median = "earn_q50",
            "80th pct" = "earn_q80"
        )
    ) |>
    ggplot(aes(
        x = edu,
        y = earnings,
        color = percentile,
        fill = percentile,
        group = edu
    )) +
    geom_path(color = "gray80", linewidth = 3) +
    geom_point(aes(size = percentile, shape = percentile)) +
    scale_color_manual(
        values = div_pal,
        aesthetics = c("color", "fill"),
        guide = guide_legend(reverse = TRUE)
    ) +
    scale_size_manual(
        values = c("20th pct" = 3.5, "80th pct" = 3.5, Median = 6.5),
        guide = guide_legend(reverse = TRUE)
    ) +
    scale_shape_manual(
        values = c("20th pct" = 25, "80th pct" = 24, Median = 19),
        guide = guide_legend(reverse = TRUE)
    ) +
    scale_x_discrete(labels = wrap_lbls) +
    scale_y_continuous(
        labels = scales::label_dollar(scale = 1 / 1000, suffix = "K"),
        breaks = seq(0, 2e5, by = 2.5e4),
        limits = c(0, NA),
        expand = expansion(mult = c(0, 0.05))
    ) +
    labs(
        title = "Lower-paid adults with graduate degrees earn about as much as higher-paid adults with no diploma",
        subtitle = "20th percentile, median, and 80th percentile individual earnings,\nMaryland adults ages 25+ by educational attainment, 2024",
        caption = "Source: US Census Bureau American Community Survey 2024 5-year estimates,\npublic use microdata sample via IPUMS",
        x = NULL,
        y = "Earnings",
        color = "Percentile",
        fill = "Percentile",
        shape = "Percentile",
        size = "Percentile"
    ) +
    theme_minimal(base_size = 12) +
    theme(
        plot.title.position = "plot",
        plot.caption.position = "plot",
        axis.ticks = element_blank(),
        panel.grid = element_line(color = "gray85"),
        panel.grid.major.x = element_blank(),
        axis.title.y.left = element_text(
            hjust = 1,
            margin = margin(8, 10, 0, 0, unit = "pt"),
            size = rel(0.8)
        ),
        legend.title = element_text(size = rel(0.8)),
        axis.line.x = element_line(color = "gray50"),
        plot.title = ggtext::element_textbox_simple(
            face = "bold",
            lineheight = 0.9,
            margin = margin_part(b = 7)
        ),
        plot.subtitle = element_text(margin = margin_part(b = 10)),
        plot.caption = element_text(
            hjust = 0,
            size = rel(0.7),
            margin = margin_part(t = 15)
        )
    )

Back to top