29  Grouped line graphs

Graphs in this chapter use fmggp2::theme_snick(), built from Nickelodeon’s Saturday night block (SNICK): a night purple background, a Nickelodeon orange frame and title, slime green, purple, blue, yellow, and pink marks, and an Arial Black title.


Requirements:

✅ a numeric (continuous) variable

✅ a numeric (date) variable

✅ at least one categorical variable

29.1 Description

Grouped line graphs use color, line style, and faceting to show how a numeric variable changes over time for each group.

29.2 Set up

PACKAGES:

Install packages.

show/hide
library(ggplot2)
library(dplyr) # for tibble() and data manipulation
library(lubridate) # for working with dates

DATA:

Every Saturday night starting in 1992, Nickelodeon kids parked themselves in front of SNICK (Clarissa Explains It All, Are You Afraid of the Dark?, and All That) and the orange couch. We’ll simulate a Nielsen style rating for Nickelodeon for every day from 1993 through 1999.

Each night of the week gets its own average (night_avg), with Saturday on top. Two smooth patterns get added to the nightly averages: a trend that rises through the middle of the decade and falls off near the end, and a summer bump when school is out. Then rnorm() adds day to day noise. Setting the seed with set.seed() means you’ll get the same 2,556 days every time you run the code below.

We’ll also create the date variables we need for grouping:

  • day_of_week (a factor from wday(), unordered so the graphs use the theme colors) and day_category ("Weekday" or "Weekend")

  • yr_mnth (the first day of each month) and yr_qtr (the year and quarter)

show/hide
set.seed(1992) # same daily ratings every time the code runs
nick_ratings <- tibble(
  date = seq(as.Date("1993-01-01"), as.Date("1999-12-31"), by = "day") # every day from 1993 to 1999
  ) |>
  mutate(
    year = year(date),
    month = month(date),
    day_of_week = factor(wday(date, label = TRUE), ordered = FALSE), # Sun through Sat
    day_category = if_else(day_of_week %in% c("Sat", "Sun"),
                           "Weekend", "Weekday"),
    yr_mnth = floor_date(date, unit = "month"), # first day of each month
    yr_qtr = factor(paste0(year, "/0", quarter(date)), ordered = TRUE), # year and quarter
    night_avg = c(Sun = 3.0, Mon = 2.4, Tue = 2.3, Wed = 2.3, # average rating for each night
                  Thu = 2.4, Fri = 2.8, Sat = 4.2)[as.character(day_of_week)], # (Saturday is SNICK night)
    trend = 0.25 * (year - 1993) - 0.04 * (year - 1993)^2, # ratings rise, then fall
    summer = 0.3 * sin(2 * pi * (month - 4) / 12), # summer bump when school is out
    rating = round(
      rnorm(n(), mean = night_avg + trend + summer, # add the three parts together
            sd = 0.35), # plus random night to night noise
      2 # two decimal places
      )
    ) |>
  select(-night_avg, -trend, -summer) # drop the helper columns
glimpse(nick_ratings)
#> Rows: 2,556
#> Columns: 8
#> $ date         <date> 1993-01-01, 1993-01-02, 19…
#> $ year         <dbl> 1993, 1993, 1993, 1993, 199…
#> $ month        <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, …
#> $ day_of_week  <fct> Fri, Sat, Sun, Mon, Tue, We…
#> $ day_category <chr> "Weekday", "Weekend", "Week…
#> $ yr_mnth      <date> 1993-01-01, 1993-01-01, 19…
#> $ yr_qtr       <ord> 1993/01, 1993/01, 1993/01, …
#> $ rating       <dbl> 2.61, 4.20, 2.65, 1.81, 2.1…

We’ll use these data in the More info section for more line graphs, but for now:

  • Group nick_ratings on year and day_category

  • Calculate the average rating as avg_rating

  • Store the data in avg_rating_day_cat_yr.

show/hide
avg_rating_day_cat_yr <- nick_ratings |>
    group_by(year, day_category) |> # one group per year for weekdays and weekends
    summarise(avg_rating = mean(rating)) |> # average rating in each group
    ungroup()
#> `summarise()` has regrouped the output.
#> ℹ Summaries were computed grouped by year and
#>   day_category.
#> ℹ Output is grouped by year.
#> ℹ Use `summarise(.groups = "drop_last")` to
#>   silence this message.
#> ℹ Use `summarise(.by = c(year, day_category))`
#>   for per-operation grouping (`?dplyr::dplyr_by`)
#>   instead.
glimpse(avg_rating_day_cat_yr)
#> Rows: 14
#> Columns: 3
#> $ year         <dbl> 1993, 1993, 1994, 1994, 199…
#> $ day_category <chr> "Weekday", "Weekend", "Week…
#> $ avg_rating   <dbl> 2.405057, 3.604519, 2.64930…

29.3 Distribution

A grouped line graph rarely plots raw values. It plots an average for each group at each point in time, and averages behave very differently from the values they’re built from. Below we’ll move from the daily ratings, to a picture, to the formula, and see why averaging makes the group differences easy to see.

COUNT IT:

Start with the first week of 1996.

show/hide
nick_ratings |>
  filter(year == 1996) |>
  select(date, day_of_week, rating) |>
  head(7)

Saturday is the clear winner this week, but the weeknights are close enough that the order could flip next week. Below we count every night in 1996 and calculate the mean and standard deviation for each night of the week.

show/hide
nick_1996 <- nick_ratings |>
  filter(year == 1996) |>
  group_by(day_of_week) |>
  summarise(n = n(), mean = mean(rating), sd = sd(rating))
nick_1996

Each night of the week shows up 52 or 53 times in a year. The Saturday mean (4.63) is well above the Tuesday mean (2.67), but any single night wanders about 0.4 rating points from its mean.

DRAW IT:

The graph below draws every Tuesday and Saturday rating from 1996 as a thin, faded line, then adds each month’s average as a thick line. The daily lines zig and zag, while the monthly averages are much smoother.

show/hide
tue_sat_1996 <- nick_ratings |>
  filter(year == 1996, day_of_week %in% c("Tue", "Sat"))
tue_sat_monthly <- tue_sat_1996 |>
  group_by(yr_mnth, day_of_week) |>
  summarise(avg_rating = mean(rating), .groups = "drop")
ggplot() +
  geom_line(data = tue_sat_1996,
            aes(x = date, y = rating, color = day_of_week),
            alpha = 1 / 3) +
  geom_line(data = tue_sat_monthly,
            aes(x = yr_mnth + 14, y = avg_rating, color = day_of_week), # middle of each month
            linewidth = 1.5) +
  labs(x = "1996", y = "Rating", color = "Night")

NAME IT:

Each day’s rating is a draw from a normal distribution centered on that night’s average for that time of year:

\[ \text{rating}_d \sim \text{Normal}\left(\mu_{\text{night}} + \text{trend}_{\text{year}} + \text{summer}_{\text{month}}, \; \sigma = 0.35\right) \]

The standard deviation of an average of \(n\) draws is \(\sigma / \sqrt{n}\) (the standard error). A monthly average of about four Saturdays wobbles by about \(0.35 / \sqrt{4} \approx 0.18\) points. Each yearly avg_rating below averages about 104 weekend nights or 261 weekday nights, so it wobbles by only \(0.35 / \sqrt{104} \approx 0.03\) or \(0.35 / \sqrt{261} \approx 0.02\) points. That’s why the yearly avg_rating lines below are so smooth, and why the gap between weekdays and weekends stands out. The sd column in COUNT IT is a little larger than 0.35 because it also includes the summer bump.

Try changing sd = 0.35 to sd = 1 and watch the daily lines in DRAW IT turn into a tangle while the yearly lines barely move.

29.4 Grammar

CODE:

  • Create labels with labs()

  • Map year to the x, avg_rating to the y, and day_category to group

  • Add the geom_line() layer and map day_category to color (inside aes())

show/hide
labs_line_graph <- labs(title = "Nickelodeon ratings",
                        caption = "Simulated data",
                        subtitle = "1993-1999",
                        y = "Average rating",
                        x = "Year",
                        color = "Day Category")

ggp2_line <- ggplot(data = avg_rating_day_cat_yr,
       mapping = aes(x = year,
                     y = avg_rating,
                     group = day_category)) +
    geom_line(aes(color = day_category))
ggp2_line +
    labs_line_graph

GRAPH:

29.5 More info

Line graphs are great for displaying relationships across variables with multiple groups (or levels). We can also use facets for multiple comparisons (i.e., small multiples).

29.5.1 Groups

In the previous graph, we can see the ratings peak around 1996 or 1997.

  • We’ll create another summarized dataset from nick_ratings, but this time we restrict the observations to the ratings between 1996 and 1997

  • Group the data on yr_qtr and day_of_week

  • Then calculate the average rating as avg_rating

show/hide
avg_rating_dow_qtr <- nick_ratings |>
    filter(year >= 1996 & year <= 1997) |>
    group_by(yr_qtr, day_of_week) |>
    summarise(avg_rating = mean(rating)) |>
    ungroup()
glimpse(avg_rating_dow_qtr)
#> Rows: 56
#> Columns: 3
#> $ yr_qtr      <ord> 1996/01, 1996/01, 1996/01, 1…
#> $ day_of_week <fct> Sun, Mon, Tue, Wed, Thu, Fri…
#> $ avg_rating  <dbl> 2.941538, 2.486923, 2.319231…

Now when we create our line graph, we will have a categorical variable with seven levels (day_of_week):

  • Create subtitle using paste0() to ensure it’s accurate if/when the underlying data is updated.

  • Label the end of each line with geom_text_repel() (from ggrepel), so we can drop the legend with theme(legend.position = "none").

show/hide
# here we create the labels (with the subtitle updating with the data)
labs_line_graph_grp <- labs(title = "Nickelodeon ratings",
                        caption = "Simulated data",
                        subtitle = paste0("Between ",
                                    min(avg_rating_dow_qtr$yr_qtr),
                                    " and ",
                                    max(avg_rating_dow_qtr$yr_qtr)),
                        y = "Average rating",
                        x = "Year/Quarter",
                        color = "Day of Week")
show/hide
# Build layer with yr_qtr and day_of_week
ggp2_line_grp <- ggplot(data = avg_rating_dow_qtr,
                   mapping = aes(x = yr_qtr,
                                 y = avg_rating,
                                 group = day_of_week)) +
        geom_line(aes(color = day_of_week))
# name each line at its end, in place of the legend
ggp2_line_grp +
    ggrepel::geom_text_repel(
        data = filter(avg_rating_dow_qtr, yr_qtr == max(yr_qtr)),
        aes(label = day_of_week, color = day_of_week),
        hjust = 0, nudge_x = 0.2, direction = "y",
        segment.color = NA) +
    scale_x_discrete(expand = expansion(add = c(0.5, 1))) +
    labs_line_graph_grp +
    theme(legend.position = "none")

The Saturday line sits on top of every quarter (that’s SNICK), and every night rises and falls with the summer bump.

29.5.2 Line Styles

  • We can make it easier to distinguish between lines in our graph by adjusting the line style (linetype and linewidth), or by changing overall opacity (alpha).

  • We’ll work through some examples below using another subset from nick_ratings.

DATA:

show/hide
avg_rating_dow_mnth <- nick_ratings |>
    filter(year >= 1996 & year <= 1997) |>
    group_by(yr_mnth, day_of_week) |>
    summarise(avg_rating = mean(rating)) |>
    ungroup()
glimpse(avg_rating_dow_mnth)
#> Rows: 168
#> Columns: 3
#> $ yr_mnth     <date> 1996-01-01, 1996-01-01, 199…
#> $ day_of_week <fct> Sun, Mon, Tue, Wed, Thu, Fri…
#> $ avg_rating  <dbl> 3.0575, 2.5040, 2.2940, 2.39…
show/hide
labs_line_styles <- labs(
    title = "Nickelodeon ratings",
    caption = "Simulated data",
    subtitle = paste0(
        "Between ",
        min(avg_rating_dow_mnth$yr_mnth),
        " and ",
        max(avg_rating_dow_mnth$yr_mnth)
    ),
    y = "Average rating",
    x = "Year-Month",
    color = "Day of Week"
)

29.5.3 alpha & linewidth

Line styles are an excellent tool for highlighting or emphasizing certain lines over others.

  • We’ll start by creating a line graph layer for Monday ("Mon"), Thursday ("Thu"), and Friday ("Fri") adjusting the opacity with alpha.
show/hide
ggp2_line_mon_thu_fri <-
    ggplot(data = filter(avg_rating_dow_mnth,
        day_of_week %in% c("Mon", "Thu", "Fri"))) +
    geom_line(
        aes(x = yr_mnth,
            y = avg_rating,
            group = day_of_week,
            color = day_of_week),
        alpha = 1 / 4,
        linewidth = 0.85)
# layer 1
ggp2_line_mon_thu_fri +
    labs_line_styles +
    theme(legend.position = "top")

29.5.4 linetype

Then we’ll change the linetype of Saturday and Sunday to "longdash" (and make this somewhat transparent with a slightly higher alpha).

show/hide
ggp2_line_sat_sun <- ggp2_line_mon_thu_fri +
    geom_line(data = filter(avg_rating_dow_mnth,
            day_of_week %in% c("Sat", "Sun")),
        aes(x = yr_mnth,
            y = avg_rating,
            group = day_of_week,
            color = day_of_week),
        alpha = 1 / 2,
        linewidth = 0.75,
        linetype = "longdash")
# layers 1 & 2
ggp2_line_sat_sun +
    labs_line_styles +
    theme(legend.position = "top")

29.5.5 Color palettes

  • Add geom_line() for Tuesday and Wednesday, and set the colors manually with scale_color_manual() (below we use a named vector of SNICK colors).

  • We also manually set the legend order by supplying the original factor levels to the breaks argument.

show/hide
# original factor levels
lev_order <- levels(avg_rating_dow_mnth$day_of_week)
# SNICK colors
snick_pal <- c(Sun = "#FF5FA2", Mon = "#29B6F6", Tue = "#8CC63F",
               Wed = "#FFD200", Thu = "#B07CE8", Fri = "#F4F1FA",
               Sat = "#F57D0D")
# layer 3
ggp2_line_pal_d <- ggp2_line_sat_sun +
    # add line
    geom_line(data = filter(avg_rating_dow_mnth,
            day_of_week %in% c("Tue", "Wed")),
        aes(x = yr_mnth,
            y = avg_rating,
            group = day_of_week,
            color = day_of_week),
        linewidth = 1.25) +
    # add palette
    scale_color_manual(
        breaks = lev_order,
        # original factor levels
        values = snick_pal)
# three layers
ggp2_line_pal_d +
    # labels
    labs_line_styles +
    # legend position
    theme(legend.position = "top")

Changing the look of the lines is a great way to highlight or emphasize some lines over others.

29.5.6 Labels

We’ll use labels to display the highest daily rating and the highest monthly average rating for a few nights of the week.

  • To accomplish this, we’re going to create two small tables of labels, label_max_dow and label_max_avg_dow, that we’ll use to label the maximum values.

  • Each has a row for every night of the week and a label variable (lbl) which we can use with geom_label(). The highest Sunday rating (4.4) happened twice (1996-08-11 and 1997-04-20), so label_max_dow has one extra row.

show/hide
label_max_dow <- nick_ratings |>
    group_by(day_of_week) |>
    summarise(max_rating = max(rating)) |>
    ungroup() |>
    inner_join(y = nick_ratings,
        by = c("max_rating" = "rating", "day_of_week")) |>
    mutate(lbl = paste0(day_of_week,
        " (", date, "), max rating = ", max_rating)) |>
    select(day_of_week, yr_mnth, max_rating, lbl)

arrange(label_max_dow, desc(max_rating)) |>
    slice(1:2)
show/hide
label_max_avg_dow <- avg_rating_dow_mnth |>
    # group by sun-sat
    group_by(day_of_week) |>
    # get max avg
    slice_max(order_by = avg_rating, n = 1) |>
    # ungroup
    ungroup() |>
    rename(max_avg_rating = avg_rating) |>
    mutate(lbl = paste0(day_of_week,
        ": max avg rating = ", round(max_avg_rating, 2)))
arrange(label_max_avg_dow, desc(max_avg_rating)) |>
    slice(1:2)

Now that we have label tables for each metric, we can filter them to the days we want to label.

  • We’ll use filter() to get the maximum values for "Wed" (in label_max_wed_dow and label_max_avg_wed_dow)
show/hide
# get wed
label_max_wed_dow <- label_max_dow |>
    filter(day_of_week == "Wed")
label_max_wed_dow
show/hide
label_max_avg_wed_dow <- label_max_avg_dow |>
    filter(day_of_week == "Wed")
label_max_avg_wed_dow

Add geom_point() and geom_label() for Wednesday.

show/hide
# point for max rating
ggp2_line_wed_max_pnts <- geom_point(
    data = label_max_wed_dow,
    aes(x = yr_mnth,
        y = max_rating,
        color = day_of_week),
    size = 2.5,
    show.legend = FALSE)

ggp2_line_wed_max_avg_pnts <-
    geom_point(
        data = label_max_avg_wed_dow,
        aes(x = yr_mnth,
            y = max_avg_rating,
            color = day_of_week),
        size = 2.5,
        alpha = 1/2,
        show.legend = FALSE)

ggp2_line_wed_max_lbl <- geom_label(
    data = label_max_wed_dow,
    aes(x = yr_mnth,
        y = max_rating,
        label = lbl,
        color = day_of_week),
    fill = "#1E1238",
    nudge_y = 0.25,
    nudge_x = 60,
    size = 2.5,
    show.legend = FALSE)

ggp2_line_wed_max_avg_lbl <-
    geom_label(data = label_max_avg_wed_dow,
        aes(x = yr_mnth,
            y = max_avg_rating,
            label = lbl,
            color = day_of_week),
        fill = "#1E1238",
        nudge_y = -0.35,
        nudge_x = 60,
        size = 2.5,
        show.legend = FALSE)

ggp2_line_pal_d +
    ggp2_line_wed_max_pnts +
    ggp2_line_wed_max_avg_pnts +
    ggp2_line_wed_max_lbl +
    ggp2_line_wed_max_avg_lbl +
    # add labels
    labs_line_styles +
    # move legend to top
    theme(legend.position = "top")

We’ll use filter() to get the maximum values for "Tue" (in label_max_tue_dow and label_max_avg_tue_dow):

show/hide
# get tue
label_max_tue_dow <- label_max_dow |>
    filter(day_of_week == "Tue")
label_max_tue_dow
show/hide
label_max_avg_tue_dow <- label_max_avg_dow |>
    filter(day_of_week == "Tue")
label_max_avg_tue_dow

Add geom_point() and geom_label() for Tuesday

show/hide
# point for max rating
ggp2_line_tue_max_pnts <-
    geom_point(data = label_max_tue_dow,
        aes(x = yr_mnth,
            y = max_rating,
            color = day_of_week),
        size = 2.5,
        show.legend = FALSE)

ggp2_line_tue_max_avg_pnts <-
    geom_point(
        data = label_max_avg_tue_dow,
        aes(x = yr_mnth,
            y = max_avg_rating,
            color = day_of_week),
        size = 2.5,
        alpha = 1/2,
        show.legend = FALSE)

ggp2_line_tue_max_lbl <-
    geom_label(data = label_max_tue_dow,
        aes(x = yr_mnth,
            y = max_rating,
            label = lbl,
            color = day_of_week),
        fill = "#1E1238",
        nudge_y = 0.25,
        nudge_x = -60,
        size = 2.5,
        show.legend = FALSE)

ggp2_line_tue_max_avg_lbl <-
    geom_label(data = label_max_avg_tue_dow,
        aes(x = yr_mnth,
            y = max_avg_rating,
            label = lbl,
            color = day_of_week),
        fill = "#1E1238",
        nudge_y = -0.35,
        nudge_x = -60,
        size = 2.5,
        show.legend = FALSE)


ggp2_line_pal_d +
    # wednesday layers
    ggp2_line_wed_max_pnts +
    ggp2_line_wed_max_avg_pnts +
    ggp2_line_wed_max_lbl +
    ggp2_line_wed_max_avg_lbl +
    # tuesday layers
    ggp2_line_tue_max_pnts +
    ggp2_line_tue_max_avg_pnts +
    ggp2_line_tue_max_lbl +
    ggp2_line_tue_max_avg_lbl +
    # add labels
    labs_line_styles +
    # move legend to top
    theme(legend.position = "top")

Notice the highest daily ratings sit well above the highest monthly averages. A single night can get lucky, but an average of four or five nights rarely does (just like we saw in the Distribution section).

29.5.7 Facets

Finally, we can use facets to view each of the line graphs separately (or small multiples).

  • We’ll create a dataset with the daily ratings between 1998-07-01 and 1999-12-31 and store it as daily_ratings_9899
show/hide
daily_ratings_9899 <- nick_ratings |>
    filter(date >= as.Date("1998-07-01")) |>
    select(date, day_category, day_of_week, rating)
glimpse(daily_ratings_9899)
#> Rows: 549
#> Columns: 4
#> $ date         <date> 1998-07-01, 1998-07-02, 19…
#> $ day_category <chr> "Weekday", "Weekday", "Week…
#> $ day_of_week  <fct> Wed, Thu, Fri, Sat, Sun, Mo…
#> $ rating       <dbl> 3.11, 2.48, 3.68, 4.25, 3.3…
  • Using facet_wrap() with a single categorical variable (var ~ .) will create a plot for each discrete level, while facet_grid() will create a level-by-level grid (specified as var ~ var).
show/hide
# labels
labs_line_graph_facet_wrap <- labs(
    title = "Nickelodeon ratings",
    caption = "Simulated data",
    subtitle = paste0(
        "Between ",
        min(daily_ratings_9899$date),
        " and ",
        max(daily_ratings_9899$date)
    ),
    y = "Rating",
    x = "Date",
    color = "Day of Week"
)
# layer
ggp2_line_facet_wrap <- ggplot(data = daily_ratings_9899,
    mapping = aes(x = date,
        y = rating,
        group = day_of_week)) +
    geom_line(aes(color = day_of_week)) +
    scale_color_manual(values = snick_pal) +
    scale_y_continuous(breaks = c(2, 3, 4, 5)) +
    scale_x_date(date_breaks = "1 year",
        date_labels = "%Y") +
    facet_wrap(day_of_week ~ .)

ggp2_line_facet_wrap +
    labs_line_graph_facet_wrap +
    theme(legend.position = "none") # the small graph names already say which night

  • It’s always a good idea to check the x and y axis text when using facets. I’ve adjusted the x and y axes above using scale_y_continuous() and scale_x_date() before adding facet_wrap()
show/hide
# labels
labs_line_graph_facet_grid <- labs(
    title = "Nickelodeon ratings",
    caption = "Simulated data",
    subtitle = paste0(
        "Between ",
        min(daily_ratings_9899$date),
        " and ",
        max(daily_ratings_9899$date)
    ),
    y = "Rating",
    x = "Date",
    color = "Day of Week"
)
# layer
ggp2_line_facet_grid <- ggplot(data = daily_ratings_9899,
    mapping = aes(x = date,
        y = rating,
        group = day_of_week)) +
    geom_line(aes(color = day_of_week)) +
    scale_color_manual(values = snick_pal) +
    scale_y_continuous(breaks = c(2, 3, 4, 5)) +
    scale_x_date(date_breaks = "1 year",
        date_labels = "%Y") +
    facet_grid(day_of_week ~ day_category)

ggp2_line_facet_grid +
    labs_line_graph_facet_grid

Seven of the 14 panels are empty, because each night of the week belongs to only one day_category (Weekday or Weekend).

The colors have been set manually using scale_color_manual() and the snick_pal named vector we created in the Color palettes section.