This post is my final project on a visualization of different activities and their enjoyment level in the UK.
This chart shows how people in the UK rate the enjoyment of different daily activities on a scale from 1 to 7.
Each point represents the mean enjoyment rating for an activity, and the horizontal line indicates the 95% confidence interval around that mean.
My goal in this section is to:
The dataset used for this replication is stored locally as an Excel file and contains one row per activity. Since no data was made available by Our World in Data the Excel file was created manually. The file is an enriched version of the UK Time Use Survey summary table and includes:
This step loads the activity-level summary statistics and sets a fixed order so the y-axis matches the reference figure.
df <- read_excel("enjoyment_uk_time_use.xlsx", sheet = "data") %>%
arrange(Reihenfolge) %>%
mutate(
# Reverse factor levels so the first activity appears at the top
Activity = factor(Activity, levels = rev(Activity)),
# Assign each activity to one of three color groups (low / mid / high enjoyment)
color_grp = case_when(
Activity %in% c(
"Doing homework","Looking for a job","Laundry etc.","Cleaning house",
"Personal services","Washing up","Other domestic","Commuting",
"Main job","Work travel","School","Education travel",
"Paid work at home","Adult care"
) ~ "Low",
Activity %in% c(
"Accompany child","Talk person, phone","Relax, do nothing","Watch TV",
"Reading","Walk dogs","Arts, musical","Listen to music","Cycling",
"Play sport","Visiting friends","Parties","Hobbies","Religious acts",
"Out-of-home leisure","Computer games","Games at home","Sleep",
"Restaurant, pub","Play with child","Go to sport","Theatre, concert"
) ~ "High",
TRUE ~ "Mid"
)
)
# Color palette
pal <- c("Low" = "#C1272D", "Mid" = "#000000", "High" = "#00847E")
The annotations are anchored to specific activities. We translate activity labels into numeric y-positions so annotate() can place brackets precisely.
y_of <- function(lbl) {
y <- match(lbl, levels(df$Activity))
if (is.na(y)) stop(paste0("Label not found in Activity levels: ", lbl))
y
}
# Low-enjoyment bracket range
y_low_top <- y_of("Doing homework")
y_low_bottom <- y_of("Adult care")
y_low_text <- (y_low_top + y_low_bottom) / 2
# Mid callout around "Second job"
y_mid_anchor <- y_of("Second job")
y_mid_top <- y_of("Work breaks") + 0.45
y_mid_bottom <- y_mid_anchor - 0.20
y_mid_text <- (y_mid_top + y_mid_bottom) / 2 + 0.05
# High-enjoyment bracket range
y_high_top <- y_of("Accompany child")
y_high_bottom <- y_of("Theatre, concert")
y_high_text <- y_high_top + 1.95
This block builds the figure with all its single elements: dotted baseline, CI bars, mean markers, manual activity labels, and the three narrative callouts.
# Visual tuning
pt_size <- 2.6
pt_stroke <- 1.0
ci_lwd <- 1.05
dot_lwd <- 0.35
x_min <- 4.0
x_max_tick <- 6.5
x_max <- 6.65
x_guide <- x_min + 0.05
# Callout positions
x_br_low <- 5.17; x_txt_low <- 5.30
x_br_mid <- 6.07; x_txt_mid <- 6.20
x_br_high <- 6.65; x_txt_high <- 5.80
p_core <- ggplot(df, aes(x = Mean, y = Activity)) +
# Dotted guide line from 4.0 to the activity mean
geom_segment(
aes(x = x_guide, xend = Mean, colour = color_grp),
linewidth = dot_lwd,
linetype = "41",
alpha = 0.55
) +
# 95% confidence interval
geom_errorbarh(
aes(xmin = CI_low, xmax = CI_high, colour = color_grp),
height = 0,
linewidth = ci_lwd
) +
# Mean point
geom_point(
aes(colour = color_grp),
shape = 21,
fill = "white",
stroke = pt_stroke,
size = pt_size
) +
# Manual activity labels
geom_text(
aes(x = x_min, label = Activity, colour = color_grp),
hjust = 1.05,
vjust = 0.50,
size = 3.05
) +
# Callout arrows
annotate("segment", x = x_br_low, xend = x_br_low, y = y_low_top, yend = y_low_bottom,
linewidth = 0.75, colour = pal["Low"]) +
annotate("segment", x = x_br_low, xend = x_br_low - 0.045, y = y_low_top, yend = y_low_top,
linewidth = 0.75, colour = pal["Low"]) +
annotate("segment", x = x_br_low, xend = x_br_low - 0.045, y = y_low_bottom, yend = y_low_bottom,
linewidth = 0.75, colour = pal["Low"]) +
annotate(
"text",
x = x_txt_low, y = y_low_text,
label = "Homework, job-seeking, domestic\nchores, schooling and paid work\nwere rated as the least enjoyable.\nThey achieved an enjoyment rating\nbetween 4 and 5 out of 7 on average.",
hjust = 0, vjust = 0.5,
size = 3.15, colour = pal["Low"], lineheight = 1.12
) +
annotate("segment", x = x_br_mid, xend = x_br_mid, y = y_mid_top, yend = y_mid_bottom,
linewidth = 0.65, colour = pal["Mid"]) +
annotate("segment", x = x_br_mid, xend = x_br_mid + 0.060, y = y_mid_bottom, yend = y_mid_bottom,
linewidth = 0.65, colour = pal["Mid"]) +
annotate(
"text",
x = x_txt_mid, y = y_mid_text,
label = "\"Second job\" showed the largest\nvariation in enjoyment ratings.\nThis likely reflects differences\nbetween those who choose to and\nthose who have to work a second job.",
hjust = 0, vjust = 0.5,
size = 3.15, colour = pal["Mid"], lineheight = 1.12
) +
annotate("segment", x = x_br_high, xend = x_br_high, y = y_high_top, yend = y_high_bottom,
linewidth = 0.75, colour = pal["High"]) +
annotate("segment", x = x_br_high, xend = x_br_high - 0.060, y = y_high_top, yend = y_high_top,
linewidth = 0.75, colour = pal["High"]) +
annotate("segment", x = x_br_high, xend = x_br_high - 0.060, y = y_high_bottom, yend = y_high_bottom,
linewidth = 0.75, colour = pal["High"]) +
annotate(
"text",
x = x_txt_high, y = y_high_text,
label = "Leisure activities such as sports,\nconcerts, games, seeing friends and family\nand hobbies were rated as most enjoyable.",
hjust = 0, vjust = 0.5,
size = 3.15, colour = pal["High"], lineheight = 1.12
) +
scale_color_manual(values = pal, guide = "none") +
scale_x_continuous(
limits = c(x_min, x_max),
breaks = seq(x_min, x_max_tick, by = 0.5),
position = "top",
expand = expansion(mult = c(0.02, 0.02))
) +
scale_y_discrete(expand = expansion(mult = c(0.01, 0.04))) +
labs(x = NULL, y = NULL) +
theme_minimal(base_size = 11) +
theme(
panel.grid.major.x = element_blank(),
panel.grid.minor.x = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
axis.text.x.top = element_text(color = "grey25", size = 10, margin = margin(b = 6)),
axis.line.x.top = element_line(color = "black", linewidth = 0.9),
axis.ticks.x.top = element_line(color = "black", linewidth = 0.6),
axis.ticks.length.x.top = unit(-4, "pt"),
plot.margin = margin(t = 8, r = 130, b = 14, l = 160)
) +
coord_cartesian(clip = "off") +
annotate(
"text", x = x_min, y = Inf,
label = "Least\nenjoyable",
hjust = 0.5, vjust = -1.35,
size = 3.3, color = "grey35", lineheight = 1.05
) +
annotate(
"text", x = (x_min + x_max_tick)/2, y = Inf,
label = "Self-reported level of enjoyment\non a scale from 1 to 7",
hjust = 0.5, vjust = -1.15,
size = 4.4, color = "grey35", lineheight = 1.10
) +
annotate(
"text", x = x_max_tick, y = Inf,
label = "Most\nenjoyable",
hjust = 0.5, vjust = -1.35,
size = 3.3, color = "grey35", lineheight = 1.05
)
header <- ggdraw() +
draw_label(
"How do people rate the enjoyment of different activities?",
x = 0.01, y = 0.92,
hjust = 0, vjust = 1,
fontface = "bold",
size = 20
) +
draw_label(
"Shown is the mean answer and the 95% confidence interval.\nData from the UK Time Use Survey (2014–15).",
x = 0.01, y = 0.58,
hjust = 0, vjust = 0.6,
size = 11,
color = "grey35",
lineheight = 1.15
)
plot_grid(
header, p_core,
ncol = 1,
rel_heights = c(0.14, 0.86)
)

I reuse the existing df from the replication part and only add the overarching group. This keeps the data consistent and avoids re-loading the file.
if (!exists("df")) stop("Object `df` not found. Run the earlier data_prep chunk first.")
if (!exists("pal")) pal <- c("Low" = "#C1272D", "Mid" = "#000000", "High" = "#00847E")
group_col <- intersect(
c(
"Übergeordnete Gruppe","Uebergeordnete Gruppe","Uebergeordnete_Gruppe","Übergeordnete_Gruppe",
"OverarchingGroup","overarching_group","ActivityGroup","activity_group",
"SuperGroup","super_group","MainGroup","main_group","Group","group","Category","category","CATEGORY"
),
names(df)
)[1]
if (is.na(group_col)) stop("Overarching group column not found in `df`.")
order_col <- intersect(c("Reihenfolge","Order","order","Rank","rank","Sort","sort","Index","index"), names(df))[1]
df_plot <- df %>%
filter(!is.na(Activity)) %>%
mutate(
super_grp = as.character(.data[[group_col]]),
Activity_lbl = as.character(Activity)
)
if (is.na(order_col)) {
df_plot <- df_plot %>% mutate(.tmp_order = row_number())
order_col <- ".tmp_order"
}
super_levels <- df_plot %>%
arrange(.data[[order_col]]) %>%
distinct(super_grp) %>%
pull(super_grp) %>%
.[!is.na(.)]
df_plot <- df_plot %>%
mutate(
super_grp = factor(super_grp, levels = super_levels),
color_grp = factor(color_grp, levels = c("Low","Mid","High"))
) %>%
arrange(super_grp, .data[[order_col]])
Each activity gets a fixed row, and I add a small gap whenever a new overarching group starts. This makes the grouping visible without extra gridlines or labels inside the data area.
row_step <- 1.00
group_gap <- row_step * 1.30
df_plot <- df_plot %>%
mutate(
row_id = row_number(),
new_group = row_id > 1L & as.character(super_grp) != as.character(lag(super_grp)),
cum_gap = cumsum(if_else(new_group, group_gap, 0)),
y_pos = (row_id - 1L) * row_step + cum_gap
)
y_max <- max(df_plot$y_pos)
df_plot <- df_plot %>% mutate(y = y_max - y_pos + 1)
band_half <- 0.50 * row_step
band_df <- df_plot %>%
arrange(desc(y)) %>%
mutate(band_id = row_number()) %>%
filter(band_id %% 2 == 0) %>%
transmute(ymin = y - band_half, ymax = y + band_half)
grp_ranges <- df_plot %>%
group_by(super_grp) %>%
summarise(
y_top = max(y),
y_bot = min(y),
y_mid = (y_top + y_bot) / 2,
grp_lbl = as.character(first(super_grp)),
.groups = "drop"
)
I keep the data layers on the right (means + intervals) and move the structure into the left margin (activity labels, brackets, group names). The legend below gives a quick read of the three color bands.
pt_size <- 2.6
pt_stroke <- 1.0
ci_lwd <- 1.05
dot_lwd <- 0.35
x_min <- 4.0
x_max_tick <- 6.5
x_max <- 6.70
x_guide <- x_min + 0.05
x_act_txt <- x_min - 0.08
x_grp_br <- x_min - 1.25
x_grp_txt <- x_min - 1.58
grp_tick <- 0.05
# "Overarching Groups" label in the left margin
x_og <- x_grp_txt - 1.0
y_og <- max(df_plot$y) + row_step * 1.55
y_ul <- y_og - row_step * 0.65
x_ul_from <- x_og + 0.0
x_ul_to <- x_og + 1.05
p_alt <- ggplot(df_plot, aes(x = Mean, y = y)) +
geom_rect(
data = band_df,
aes(xmin = -Inf, xmax = Inf, ymin = ymin, ymax = ymax),
inherit.aes = FALSE,
fill = "grey97",
alpha = 0.90
) +
geom_segment(
aes(x = x_guide, xend = Mean, yend = y, colour = color_grp),
linewidth = dot_lwd,
linetype = "41",
alpha = 0.55
) +
geom_segment(
aes(x = CI_low, xend = CI_high, yend = y, colour = color_grp),
linewidth = ci_lwd
) +
geom_point(
aes(colour = color_grp),
shape = 21,
fill = "white",
stroke = pt_stroke,
size = pt_size
) +
geom_text(
aes(x = x_act_txt, label = Activity_lbl, colour = color_grp),
hjust = 1.00,
vjust = 0.50,
size = 2.95
) +
geom_segment(
data = grp_ranges,
aes(x = x_grp_br, xend = x_grp_br, y = y_top, yend = y_bot),
inherit.aes = FALSE,
linewidth = 0.85,
colour = "grey35"
) +
geom_segment(
data = grp_ranges,
aes(x = x_grp_br, xend = x_grp_br + grp_tick, y = y_top, yend = y_top),
inherit.aes = FALSE,
linewidth = 0.85,
colour = "grey35"
) +
geom_segment(
data = grp_ranges,
aes(x = x_grp_br, xend = x_grp_br + grp_tick, y = y_bot, yend = y_bot),
inherit.aes = FALSE,
linewidth = 0.85,
colour = "grey35"
) +
geom_text(
data = grp_ranges,
aes(x = x_grp_txt, y = y_mid, label = grp_lbl),
inherit.aes = FALSE,
hjust = 1,
vjust = 0.5,
size = 3.05,
colour = "grey25",
fontface = "bold"
) +
annotate(
"text",
x = x_og, y = y_og,
label = "Overarching Groups",
hjust = 0, vjust = 0.5,
size = 3.4, colour = "grey25", fontface = "bold"
) +
annotate(
"segment",
x = x_ul_from, xend = x_ul_to, y = y_ul, yend = y_ul,
linewidth = 0.8, colour = "grey25"
) +
scale_color_manual(values = pal, guide = "none") +
scale_x_continuous(
breaks = seq(x_min, x_max_tick, by = 0.5),
position = "top",
expand = expansion(mult = c(0.02, 0.02))
) +
scale_y_continuous(
breaks = NULL,
expand = expansion(mult = c(0.01, 0.04))
) +
labs(x = NULL, y = NULL) +
theme_minimal(base_size = 11) +
theme(
panel.grid.major.x = element_blank(),
panel.grid.minor.x = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor.y = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
axis.text.x.top = element_text(color = "grey25", size = 10, margin = margin(b = 6)),
axis.line.x.top = element_line(color = "black", linewidth = 0.9),
axis.ticks.x.top = element_line(color = "black", linewidth = 0.6),
axis.ticks.length.x.top = unit(-4, "pt"),
plot.margin = margin(t = 8, r = 40, b = 6, l = 320)
) +
coord_cartesian(xlim = c(x_min, x_max), clip = "off") +
annotate(
"text", x = x_min, y = Inf,
label = "Least\nenjoyable",
hjust = 0.5, vjust = -1.35,
size = 3.3, color = "grey35", lineheight = 1.05
) +
annotate(
"text", x = (x_min + x_max_tick)/2, y = Inf,
label = "Self-reported level of enjoyment\non a scale from 1 to 7",
hjust = 0.5, vjust = -1.15,
size = 4.4, color = "grey35", lineheight = 1.10
) +
annotate(
"text", x = x_max_tick, y = Inf,
label = "Most\nenjoyable",
hjust = 0.5, vjust = -1.35,
size = 3.3, color = "grey35", lineheight = 1.05
)
header_alt <- ggdraw() +
draw_label(
"How do people rate the enjoyment of different activities?",
x = 0.01, y = 0.92,
hjust = 0, vjust = 1,
fontface = "bold",
size = 20
) +
draw_label(
"Shown is the mean answer and the 95% confidence interval.\nData from the UK Time Use Survey (2014–15).",
x = 0.01, y = 0.58,
hjust = 0, vjust = 0.6,
size = 11,
color = "grey35",
lineheight = 1.15
)
leg_box_w <- 0.10
leg_box_h <- 0.18
legend_alt <- ggdraw() +
draw_grob(
grid::rectGrob(
x = 0.25, y = 0.55, width = leg_box_w, height = leg_box_h,
gp = grid::gpar(fill = pal["Low"], col = NA)
)
) +
draw_label("< 4.75", x = 0.25 + 0.08, y = 0.55, hjust = 0, vjust = 0.5, size = 12) +
draw_grob(
grid::rectGrob(
x = 0.50, y = 0.55, width = leg_box_w, height = leg_box_h,
gp = grid::gpar(fill = pal["Mid"], col = NA)
)
) +
draw_label("\u2264 5.50", x = 0.50 + 0.08, y = 0.55, hjust = 0, vjust = 0.5, size = 12) +
draw_grob(
grid::rectGrob(
x = 0.75, y = 0.55, width = leg_box_w, height = leg_box_h,
gp = grid::gpar(fill = pal["High"], col = NA)
)
) +
draw_label("> 5.50", x = 0.75 + 0.08, y = 0.55, hjust = 0, vjust = 0.5, size = 12)
plot_grid(
header_alt, p_alt, legend_alt,
ncol = 1,
rel_heights = c(0.14, 0.78, 0.08)
)

I add a compact summary per overarching group to support the visual pattern with a few simple numbers. Highlighting the highest and lowest average mean makes the ranking explicit at a glance.
| Overarching group | # Activities | Mean (avg across activities) | Median | Min mean | Max mean |
|---|---|---|---|---|---|
| Work / Study | 8 | 4.86 | 4.75 | 4.20 | 5.45 |
| Domestic / Household | 8 | 4.82 | 4.53 | 4.40 | 5.55 |
| Travel / Commuting | 5 | 4.84 | 4.75 | 4.60 | 5.10 |
| Care / Family | 6 | 5.52 | 5.53 | 4.85 | 6.35 |
| Personal & Wellbeing | 4 | 5.64 | 5.68 | 4.95 | 6.25 |
| Social / Out-of-home | 6 | 6.07 | 6.20 | 5.00 | 6.60 |
| Communication & Media | 6 | 5.57 | 5.58 | 5.10 | 5.95 |
| Leisure (Active) | 10 | 5.98 | 6.03 | 5.30 | 6.30 |
| Leisure (Passive) | 2 | 5.75 | 5.75 | 5.70 | 5.80 |
The chart enables quick comparisons across many activities, but it is fairly hand-tuned. Custom spacing, manual label placement, and off-plot elements improve readability, yet they also make the workflow fragile if the dataset changes (renamed activities, new rows, different ordering).
Text and figures are licensed under Creative Commons Attribution CC BY 4.0. The figures that have been reused from other sources don't fall under this license and can be recognized by a note in their caption: "Figure from ...".
For attribution, please cite this work as
Kuehnast (2026, Jan. 14). Data Visualization | MSc CSS: Enjoyment of different activities in the UK. Retrieved from https://csslab.uc3m.es/dataviz/projects/2025/100572934/
BibTeX citation
@misc{kuehnast2026enjoyment,
author = {Kuehnast, Joris},
title = {Data Visualization | MSc CSS: Enjoyment of different activities in the UK},
url = {https://csslab.uc3m.es/dataviz/projects/2025/100572934/},
year = {2026}
}