ResBaz 2026 Data Storytelling with R and ggplot

This workshop will build upon “Introduction to R for Data Analysis”, and assume you have a basic understanding of R, reading in data, and subsetting data. We will use the dplyr package for data manipulation and the ggplot2 package to create customised visualisation.

R Markdown

This is an R Markdown document. Markdown is a simple formatting syntax for authoring HTML, PDF, and MS Word documents. For more details on using R Markdown see http://rmarkdown.rstudio.com.

When you click the Knit button a document will be generated that includes both content as well as the output of any embedded R code chunks within the document. Embed R code chunk can be run by pressing the green triangle in the top right of the code chunk. You can also run all code chunks in the document by clicking the Run button in the toolbar above.

Setting working directory

Set your working directory to the folder where the data and this file is located. This can be done in RStudio by Session > Set Working Directory > To Source File Location. Alternatively, you can use the setwd() function in R.

Your folder should contain: - childhood_24month_immunisation_2015_2026.csv - the data we will use in this workshop - resbaz_ggplot_2026.Rmd - this R Markdown file

Storytelling 1: who is your audience?

For data analysis and exploration. For yourself or the team

For data communication and dissemination. For an audience

For art. For yourself and an audience (https://www.data-to-art.com/)

Storytelling 2: setting

The New Zealand government has set a target of 95% immunisation coverage for 24-month-old children by 2030. The data we will explore is from the Health New Zealand - Te Whatu Ora, covering the years 2015 to 2026. https://www.healthnz.govt.nz/about-us/health-data/data-sets-and-collections/immunisation-data-and-statistics/immunisation-coverage

“Health NZ is working towards a goal of 95% of tamariki aged 24-months to be fully immunised by 2030, and an interim target of 84% by June 2025.”

Select a health question to answer:

  1. How has the overall immunisation coverage for 24-month-old children changed over the years (2015-2026) in New Zealand?
  2. Which district health boards have the highest/lowest immunisation coverage rates for 24-month-old children in 2026?
  3. Are there significant differences in immunisation coverage among different ethnic groups for 24-month-old children in New Zealand in 2026?

ggplot2 package

The “GG” in ggplot2 stands for “Grammar of Graphics”. It is a powerful and flexible package for creating graphics in R. The package allows you to build plots layer by layer, making it easy to customise and enhance your visualisations. The ggplot2 package is part of the tidyverse package, which also includes dplyr, tidyr, readr, and other packages for data manipulation and visualisation.

# start a line with a hash (#) for a comment inside a code chunk

# if you don't already have tidyverse, run the line of code below by 
# removing the hash then run by pressing the green triangle in the top right of the code chunk

# install.packages("tidyverse")

library(tidyverse)
## Warning: package 'ggplot2' was built under R version 4.3.3
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.4     ✔ readr     2.1.5
## ✔ forcats   1.0.0     ✔ stringr   1.5.1
## ✔ ggplot2   3.5.2     ✔ tibble    3.2.1
## ✔ lubridate 1.9.3     ✔ tidyr     1.3.1
## ✔ purrr     1.0.2     
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
# option 2, 
# if you don't already have tidyverse and tidyverse is taking too long to download
# we will only require these four packages ggplot2, dplyr, tidyr, readr

# install.packages(c("ggplot2", "dplyr", "tidyr", "readr"))
# 
# library(ggplot2)
# library(dplyr)
# library(tidyr)
# library(readr)

If the data is not found, please check the data in in the same folder as this Rmd file, and check that the working directory is set correctly. Instructions above.

# Load the data
child_imm_raw <- read_csv("./childhood_24month_immunisation_2015_2026.csv")
## Rows: 1260 Columns: 6
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (3): district_health_boards, region, ethnicity
## dbl (3): year, num_eligible, fully_immunised_for_age
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# show the first 6 rows of data
print(head(child_imm_raw))
## # A tibble: 6 × 6
##    year district_health_boards region         ethnicity         num_eligible
##   <dbl> <chr>                  <chr>          <chr>                    <dbl>
## 1  2015 Auckland               Northern       Total                     6003
## 2  2015 Auckland               Northern       Maori                      737
## 3  2015 Auckland               Northern       Pacific                   1087
## 4  2015 Auckland               Northern       Asian                     1793
## 5  2015 Auckland               Northern       European or Other         2386
## 6  2015 Bay of Plenty          Te Manawa Taki Total                     3002
## # ℹ 1 more variable: fully_immunised_for_age <dbl>
# shape of the data (number of rows and columns)
# should be 1260 rows and 6 columns
print(dim(child_imm_raw))
## [1] 1260    6
# print unique district health boards
# should be 21, i.e. 20 districts and "National"
print(child_imm_raw |> select (district_health_boards) |> unique())
## # A tibble: 21 × 1
##    district_health_boards
##    <chr>                 
##  1 Auckland              
##  2 Bay of Plenty         
##  3 Canterbury            
##  4 Capital and Coast     
##  5 Counties Manukau      
##  6 Hawkes Bay            
##  7 Hutt Valley           
##  8 Lakes                 
##  9 MidCentral            
## 10 National              
## # ℹ 11 more rows

Let’s demonstrate how to build up a plot in ggplot2. We will plot the immunisation coverage of 24-month-old children in each district health board in 2020.

  1. Calculate a new column perc_fully_immunised_for_age which is the percentage of children fully immunised for their age group. This is done by dividing the number of fully immunised children (fully_immunised_for_age) by the number of eligible children (num_eligible)
  2. Select the rows of data we want (ethnicity = “Total”, year = 2020); be careful there is a district health board called “National”, which is not a district health board, but a national total. We will exclude this from the plot.

Exercise 1

Fill in the blanks to select the desired data, there should be 20 rows of data and 7 columns in the resulting data frame.

# fill in the blanks below

# create the percentage column here, we can reuse child_imm later
child_imm <- child_imm_raw |>
  mutate(perc_fully_immunised_for_age = fully_immunised_for_age / num_eligible) 

# create a new data frame child_imm_2020_dhb for this plot
child_imm_2020_dhb <- child_imm |>
  filter(ethnicity == "Total", year == 2020, district_health_boards != "National")

# this should have 20 rows and 7 columns
print(child_imm_2020_dhb)
## # A tibble: 20 × 7
##     year district_health_boards region         ethnicity num_eligible
##    <dbl> <chr>                  <chr>          <chr>            <dbl>
##  1  2020 Auckland               Northern       Total             5249
##  2  2020 Bay of Plenty          Te Manawa Taki Total             3179
##  3  2020 Canterbury             Te Waipounamu  Total             6416
##  4  2020 Capital and Coast      Central        Total             3242
##  5  2020 Counties Manukau       Northern       Total             8313
##  6  2020 Hawkes Bay             Central        Total             2154
##  7  2020 Hutt Valley            Central        Total             2035
##  8  2020 Lakes                  Te Manawa Taki Total             1590
##  9  2020 MidCentral             Central        Total             2243
## 10  2020 Nelson Marlborough     Te Waipounamu  Total             1586
## 11  2020 Northland              Northern       Total             2365
## 12  2020 South Canterbury       Te Waipounamu  Total              625
## 13  2020 Southern               Te Waipounamu  Total             3383
## 14  2020 Tairawhiti             Te Manawa Taki Total              724
## 15  2020 Taranaki               Te Manawa Taki Total             1682
## 16  2020 Waikato                Te Manawa Taki Total             5620
## 17  2020 Wairarapa              Central        Total              535
## 18  2020 Waitemata              Northern       Total             7655
## 19  2020 West Coast             Te Waipounamu  Total              334
## 20  2020 Whanganui              Central        Total              841
## # ℹ 2 more variables: fully_immunised_for_age <dbl>,
## #   perc_fully_immunised_for_age <dbl>

ggplot2 uses a layered approach to building plots. See: https://ggplot2.tidyverse.org/articles/ggplot2.html

The basic structure of a ggplot2 plot is:

# ggplot(<data>, 
#        aes(<mapping>)
#        ) +
#  <geom_function>() +
#  <theme_function>()

Notice how layers are added with the + operator. The aes() function is used to map variables in the data to aesthetics of the plot, such as x and y axes, colour, size, etc. The geom_function() is used to specify the type of plot (e.g., geom_bar(), geom_line(), geom_point(), etc.). The theme_function() is used to customise the appearance of the plot.

ggplot(child_imm_2020_dhb,
       aes(
         x = perc_fully_immunised_for_age,
         y = fct_reorder(district_health_boards, num_eligible),
         fill = -num_eligible,
         color = region
       )) +
  geom_col() +
  coord_cartesian(xlim = c(0.75,1)) +
  labs(
    title = "2020 immunisation data",
    x = "Coverage (%)",
    y = "District"
  )

ggplot(child_imm_2020_dhb) # data only

ggplot(child_imm_2020_dhb, 
       aes(x = district_health_boards, 
           y = perc_fully_immunised_for_age) # added aesthetics, but no geometry
       ) 

ggplot(child_imm_2020_dhb, 
       aes(x = district_health_boards, 
           y = perc_fully_immunised_for_age)
       ) +
  geom_col() +# this adds a column plot layer
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Replacing the labels with plain English and adding a title will improve readability. To avoid the x-axis labels overlapping, we can rotate the labels by 45 degrees using the theme() function. We can also set the y-axis to go from 0% to 100%.

ggplot(child_imm_2020_dhb, 
       aes(x = district_health_boards, 
           y = perc_fully_immunised_for_age)
       ) +
  geom_col() +
  # NEW LINES:
  labs(title = "Immunisation Coverage of 24-month-old Children in 2020", # title at the top of the plot
       x = "District Health Boards", # x-axis label
       y = "Percentage Fully Immunised") + # y-axis label
  scale_y_continuous(labels = scales::percent_format()) + # this line formats the y-axis labels as percentages 
  coord_cartesian(ylim = c(0,1)) + # this line sets the y-axis limits to 0% to 100%
  theme(axis.text.x = element_text(angle = 45, hjust = 1)) # this rotates the x-axis labels by 45 degrees

Alternatively, we can place the districts on the y-axis and the immunisation coverage on the x-axis

ggplot(child_imm_2020_dhb, 
       # NEW LINES: swapped x and y
       aes(x = perc_fully_immunised_for_age,
           y = district_health_boards)
       ) +
  geom_col() +
  # NEW LINES:
  labs(title = "Immunisation Coverage of 24-month-old Children in 2020", 
       x = "Percentage Fully Immunised", 
       y = "District Health Boards") +
  scale_x_continuous(labels = scales::percent_format()) + 
  coord_cartesian(xlim = c(0,1))

You may also choose to focus on the range 75% to 100%

ggplot(child_imm_2020_dhb, 
       aes(x = district_health_boards, 
           y = perc_fully_immunised_for_age)
       ) +
  geom_col() +
  labs(title = "Immunisation Coverage of 24-month-old Children in 2020",
       x = "District Health Boards",
       y = "Percentage Fully Immunised") +
  scale_y_continuous(labels = scales::percent_format()) + # this line formats the y-axis labels as percentages 
  # NEW LINE:
  coord_cartesian(ylim = c(0.75,1)) + # this line sets the y-axis limits to 75% to 100%
  theme(axis.text.x = element_text(angle = 45, hjust = 1)) 

We can make the plot more informative by colouring the bars with the region. We can do this by mapping the region variable to the fill aesthetic in the aes() function.

ggplot(child_imm_2020_dhb, 
       aes(x = district_health_boards, 
           y = perc_fully_immunised_for_age, 
           # NEW LINE:
           fill = region) # mapping the fill aesthetic to region
       ) +
  geom_col() +
  labs(title = "Immunisation Coverage of 24-month-old Children in 2020",
       x = "District Health Boards",
       y = "Percentage Fully Immunised") +
  scale_y_continuous(labels = scales::percent_format()) +
  coord_cartesian(ylim = c(0.75,1)) + 
  theme(axis.text.x = element_text(angle = 45, hjust = 1)) 

To bring further clarity, we can order the bars in ascending order of percentage fully immunised

ggplot(child_imm_2020_dhb, 
       # NEW LINE:
       aes(x = fct_reorder(district_health_boards, perc_fully_immunised_for_age),
           y = perc_fully_immunised_for_age, 
           fill = region)
       ) +
  geom_col() +
  labs(title = "Immunisation Coverage of 24-month-old Children in 2020",
       x = "District Health Boards",
       y = "Percentage Fully Immunised") +
  scale_y_continuous(labels = scales::percent_format()) +
  coord_cartesian(ylim = c(0.75,1)) + 
  theme(axis.text.x = element_text(angle = 45, hjust = 1)) 

Exercise 2

Explore data for your chosen question.

  1. Filter the data
    1. (option 1) filter for the national district health board, total ethnicity
    2. (option 2) filter for the year 2026, total ethnicity
    3. (option 3) filter for the year 2026, national district health board
  2. Make an scatter plot of the data (geom_point())

You may also try other geometries, such as geom_line() or geom_col(), and use different aesthetics to map the data to the plot, such as colour, fill, size, shape, linetype, etc. (just remember to adjust the dataframe appropriately to include the correct rows and columns you want to plot).

# fill in the blanks below
# define a new dataframe to use for this plot
child_imm_year <- child_imm |>
  filter(
    district_health_boards == "National", # filter 1
    ethnicity == "Total" # filter 2
    )


p <- ggplot(child_imm_year, # data
       aes(x = year, # mapping x axis
           y = perc_fully_immunised_for_age) # mapping y axis
       ) +
  geom_line() + # geom layer
  scale_x_continuous(breaks=2015:2026)

print(p)

library(plotly)
## Warning: package 'plotly' was built under R version 4.3.3
## 
## Attaching package: 'plotly'
## The following object is masked from 'package:ggplot2':
## 
##     last_plot
## The following object is masked from 'package:stats':
## 
##     filter
## The following object is masked from 'package:graphics':
## 
##     layout
ggplotly(p)

Storytelling 3: characters

Pick out a few data points or patterns to focus your storytelling.

Main “characters” data points are the center of your story, they will experience conflict and resolution, and they may interact with the world or other characters in the story.

Secondary “characters” data points (rivals, antagonist, best friend) that interact with the main characters, they enhance the story and provide context or contrast to the main characters. In a graph, these could be additional data points or variables.

(option 1a) might choose to measure against the government targets 84% (by June 2025), 87% (by June 2026), and 95% (by 2030)

(option 1b) might choose to characterise the year-on-year difference and the improvements since 2024

(option 1c) might choose to include data from each region to show the differences in immunisation coverage

(option 2) might choose to characterise the Tairawhiti region, which has the second lowest immunisation coverage in 2026 but was average in 2015

(option 3) might choose to focus on Maori children, who historically had lower immunisation coverage than non-Maori children.

Storytelling 4: message, conflict & resolution

The message is the main point you want to convey to your audience. It should be clear, concise, and supported by the data. This could be added as text in a title, subtitle, or as an annotation in the plot.

The conflict is the main point of interest in your data. This could be a trend (e.g. general growth), a pattern (e.g. sudden drop), or a specific data point (e.g. anomaly) that stands out. It should be something that captures the attention of the audience and makes them want to learn more.

The resolution is the conclusion or insight, such as additional data that gives more insights to the conflict, or actionable insights.

Exercise 3

Choose something to highlight within your data.

  1. In one sentence, summarise the message you want to convey. Make this your title or subtitle.

  2. Enhance and build upon your previous plot to support this message with your graph. You can use the annotate() function to add text annotate("text",...) or arrows annotate("segement",...) to your plot to highlight specific data points or patterns. Adding additional geometries with colour and other aesthetics can also help to you focus the attention of the reader.

# fill in the blanks below
# define a new dataframe to use for this plot
child_imm_year <- child_imm |>
  filter(
    district_health_boards == "National", # filter 1
    ethnicity == "Total" # filter 2
    )


ggplot(child_imm_year, # data
       aes(x = year, # mapping x axis
           y = perc_fully_immunised_for_age) # mapping y axis
       ) +
  geom_line() + # geom layer
  annotate("segment",
           x = 2018, xend = 2018,
           y = 0.9, yend = 0.85,
           arrow = arrow()) +
  annotate("text", 
           x = 2024,
           y = 0.78,
           label = "some notes") +
  geom_hline(aes(yintercept = 0.85),
             colour="blue"
             ) +
  geom_vline(aes(xintercept = 2024), colour = "green") + 
  scale_x_continuous(breaks=2015:2026)

# write your answer here
# write your answer here
child_imm_year <- child_imm |>
  filter(district_health_boards == "National", 
         ethnicity == "Total")

perc_2026 = child_imm_year |> filter(year == 2026) |> pull(perc_fully_immunised_for_age)

ggplot(child_imm_year, 
       aes(x = year, y = perc_fully_immunised_for_age)
       ) +
  geom_point() +
  
  geom_hline(yintercept = 0.95, linetype = "dashed", color = "blue") +
  annotate("text", 
           x = 2024, y = 0.95, 
           label = "95% target coverage 2030",
           colour = "blue",
           hjust = 1, vjust = 1.1, size = 4) +
  geom_hline(yintercept = 0.84, linetype = "dashed", color = "darkgreen") +
  annotate("text", 
           x = 2020, y = 0.84, 
           label = "84% target coverage June 2025",
           colour = "darkgreen",
           hjust = 1, vjust = 1.1, size = 4) +
  annotate("text", 
           x = 2026, y = perc_2026, 
           label = paste0(round(perc_2026*100,1), "%"),
           colour = "black",
           hjust = 0.5, vjust = 2, size = 4) +
  scale_x_continuous(breaks = seq(2015, 2026, 1)) +
  scale_y_continuous(labels = scales::percent_format(), limits = c(0.6,1)) +
  labs(title = "Immunisation Coverage of 24-month-old Children in New Zealand",
       subtitle = "The national average has almost reached interim target, some districts have met the milestone",
       x = "Year",
       y = "Percentage Fully Immunised",
       caption = "Data: Ministry of Health NZ") +
  theme_minimal() +
  theme(legend.position = "none", # remove the legend
        panel.grid.major.x = element_blank(), # remove x axis grid lines
        panel.grid.minor.x = element_blank()) 

# write your answer here
child_imm_year <- child_imm |>
  filter( ethnicity == "Total")

ggplot(child_imm_year, 
       aes(x = year, y = perc_fully_immunised_for_age, colour = district_health_boards)
       ) +
  geom_line(aes(group = district_health_boards), colour = "grey") +
  geom_line(data=child_imm_year|>filter(district_health_boards=="National"),
            # aes(x = year, y = perc_fully_immunised_for_age), # this line is not needed as we are using the same aes() as the main plot
            linewidth=3) +
  annotate("text", 
           x = 2015, y = 0.65, 
           label = "National coverage is highlighted in bold",
           hjust = 0, vjust = 1, size = 4) +
  geom_hline(yintercept = 0.95, linetype = "dashed", color = "grey") +
  annotate("text", 
           x = 2030, y = 0.95, 
           label = "95% target coverage 2030",
           colour = "grey",
           hjust = 1, vjust = 1.1, size = 4) +
  scale_x_continuous(breaks = seq(2015, 2030, 1), limits = c(2015,2030)) +
  scale_y_continuous(labels = scales::percent_format()) +
  labs(title = "Immunisation Coverage of 24-month-old Children in New Zealand",
      subtitle = "A steady progress towards the national 95% target in most districts since the target set in 2024",
       x = "Year",
       y = "Percentage Fully Immunised", 
       caption = "Data: Ministry of Health NZ") + 
  theme_minimal() +
  theme(legend.position = "none", # remove the legend
        panel.grid.major.x = element_blank(), # remove x axis grid lines
        panel.grid.minor.x = element_blank()) 

child_imm_year <- child_imm |>
  filter(ethnicity == "Total") |> 
  group_by(year) |> 
  mutate(rank_desc = min_rank(desc(perc_fully_immunised_for_age))) |> 
  ungroup() |> 
  filter(district_health_boards %in% c("National", "Tairawhiti"))

library(gganimate)

ggplot(child_imm_year, 
       aes(x = year, y = perc_fully_immunised_for_age, colour = district_health_boards)
       ) +
  geom_line() +
  scale_x_continuous(breaks = seq(2015, 2026, 1), limits = c(2015,2026.5)) +
  scale_y_continuous(labels = scales::percent_format()) +
  geom_text(data = child_imm_year |> filter(district_health_boards == "Tairawhiti"),
           aes(x = year,
               y = perc_fully_immunised_for_age,
               label = paste0("Top ", rank_desc)),
           hjust = 0, vjust = 0, size = 3) +
  labs(title = "Immunisation Coverage of 24-month-old Children in New Zealand",
       subtitle = "Tairāwhiti experienced a sharper drop in 2017-2024 than the rest of NZ",
       x = "Year",
       y = "Percentage Fully Immunised") +
  theme(legend.title=element_blank()) + 
  transition_states(
    year,
    transition_length = 2,
    state_length = 1) +
  transition_reveal(year) +
  ease_aes('linear')
## `geom_line()`: Each group consists of only one observation.
## ℹ Do you need to adjust the group aesthetic?
## `geom_line()`: Each group consists of only one observation.
## ℹ Do you need to adjust the group aesthetic?