---
title: "The Planet Still Breathes"
subtitle: "Decade-averaged CO₂ seasonal cycle at Mauna Loa, 1960s–2020s. Each curve shows how atmospheric CO₂ rises and falls across the year — driven by Northern Hemisphere plant growth and decay. +101 ppm higher baseline since the 1960s (~320 ppm to ~421 ppm)"
description: "This chart isolates the seasonal 'breath' of atmospheric CO₂ from the Mauna Loa Observatory record — the annual rise and fall driven by Northern Hemisphere plant growth and decay. By plotting decade-averaged seasonal anomalies (deviation from the long-run trend) for four decades from the 1960s to the 2020s, the chart reveals a striking pattern: the shape of Earth's annual CO₂ cycle has remained remarkably stable across 60 years, even as the absolute baseline has risen by +101 ppm. Built with R and ggplot2 using NOAA GML monthly mean data."
date: "2026-04-23"
author:
- name: "Steven Ponce"
url: "https://stevenponce.netlify.app"
citation:
url: "https://stevenponce.netlify.app/data_visualizations/30DayChartChallenge/2026/30dcc_2026_23.html"
categories: ["30DayChartChallenge", "2026"]
tags: [
"30DayChartChallenge",
"Timeseries",
"Seasons",
"Keeling Curve",
"CO2",
"Climate Change",
"Seasonal Cycle",
"Line Chart",
"NOAA",
"Mauna Loa",
"ggplot2",
"spline interpolation"
]
image: "thumbnails/30dcc_2026_23.png"
format:
html:
toc: true
toc-depth: 5
code-link: true
code-fold: true
code-tools: true
code-summary: "Show code"
editor_options:
chunk_output_type: inline
execute:
freeze: true
cache: true
error: false
message: false
warning: false
eval: true
---
{#fig-1}
### [**Steps to Create this Graphic**]{.mark}
#### [1. Load Packages & Setup]{.smallcaps}
```{r}
#| label: load
#| warning: false
#| message: false
#| results: "hide"
## 1. LOAD PACKAGES & SETUP ----
suppressPackageStartupMessages({
pacman::p_load(
tidyverse, ggtext, showtext,
janitor, scales, glue
)
})
### |- figure size ----
camcorder::gg_record(
dir = here::here("temp_plots"),
device = "png",
width = 10,
height = 8,
units = "in",
dpi = 320
)
# Source utility functions
suppressMessages(source(here::here("R/utils/fonts.R")))
source(here::here("R/utils/social_icons.R"))
source(here::here("R/utils/image_utils.R"))
source(here::here("R/themes/base_theme.R"))
```
#### [2. Read in the Data]{.smallcaps}
```{r}
#| label: read
#| include: true
#| eval: true
#| warning: false
energy_raw <- read_csv(here("data/30DayChartChallenge/2026/owid-energy-data.csv"))
### |- read and parse NOAA MLO monthly CO2 ----
# Comment lines start with #; no formal header row
# Missing values coded as -99.99
co2_raw <- read_csv(
here::here("data/30DayChartChallenge/2026/co2_mm_mlo.csv"),
comment = "#",
col_names = c("year", "month", "decimal_date", "average",
"deseasonalized", "ndays", "sdev", "unc"),
col_types = cols(.default = col_double())
) |>
filter(!is.na(year)) # drop the header row that slips past comment="#"
```
#### [3. Examine the Data]{.smallcaps}
```{r}
#| label: examine
#| include: true
#| eval: true
#| results: 'hide'
#| warning: false
glimpse(co2_raw)
range(co2_raw$year)
sum(co2_raw$average < 0) # count missing (-99.99 sentinel)
```
#### [4. Tidy Data]{.smallcaps}
```{r}
#| label: tidy
#| warning: false
### |- clean and derive seasonal anomaly ----
co2_clean <- co2_raw |>
filter(average > 0, deseasonalized > 0) |>
mutate(
seasonal_anomaly = average - deseasonalized,
decade = floor(year / 10) * 10
) |>
filter(decade >= 1960)
### |- decade-averaged seasonal waveform ----
# Keep 4 key decades: 1960s (baseline), 1980s, 2000s, 2020s (now)
key_decades <- c(1960, 1980, 2000, 2020)
co2_waveform_raw <- co2_clean |>
filter(decade %in% key_decades) |>
group_by(decade, month) |>
summarise(
mean_anomaly = mean(seasonal_anomaly, na.rm = TRUE),
.groups = "drop"
)
# Spline-interpolate each decade curve to 120 points (10 per month interval)
# This converts the angular 12-point polygon into a smooth breathing waveform
co2_waveform <- co2_waveform_raw |>
group_by(decade) |>
group_modify(~ {
spl <- splinefun(.x$month, .x$mean_anomaly, method = "periodic")
month_seq <- seq(1, 12, length.out = 120)
tibble(month = month_seq, mean_anomaly = spl(month_seq))
}) |>
ungroup() |>
mutate(
decade_fct = factor(decade, levels = key_decades),
month_abb = month.abb[round(month)]
)
### |- annotation data: decade mean CO2 for annotation text ----
decade_means <- co2_clean |>
group_by(decade) |>
summarise(mean_co2 = mean(average, na.rm = TRUE), .groups = "drop")
# values for annotation:
co2_1960s <- decade_means |>
filter(decade == 1960) |> pull(mean_co2) |> round(0)
co2_2020s <- decade_means |>
filter(decade == 2020) |> pull(mean_co2) |> round(0)
### |- x-axis month labels ----
month_labels <- month.abb
### |- direct end-line labels — 1960s and 2020s only ----
label_data <- co2_waveform |>
group_by(decade) |>
slice_max(month, n = 1) |>
ungroup() |>
filter(decade %in% c(1960, 2020)) |>
mutate(label = glue("{decade}s"))
### |- zero reference line data ----
zero_line <- tibble(month = 1:12, y = 0)
### |- split data: context (faded) vs hero (2020s) ----
co2_context <- co2_waveform |> filter(decade != 2020)
co2_hero <- co2_waveform |> filter(decade == 2020)
```
#### [5. Visualization Parameters]{.smallcaps}
```{r}
#| label: params
#| include: true
#| warning: false
### |- plot aesthetics ----
colors <- get_theme_colors(
palette = list(
"1960" = "#C2C2C2",
"1980" = "#9E9E9E",
"2000" = "#707070",
"2020" = "#6B0F1A",
"bg" = "#F5F2EE",
"text" = "#2C2C2C",
"grid" = "#DDD9D4"
)
)
# extract as plain scalars
decade_colors <- c(
"1960" = colors$palette[["1960"]],
"1980" = colors$palette[["1980"]],
"2000" = colors$palette[["2000"]],
"2020" = colors$palette[["2020"]]
)
# line widths
decade_linewidths <- c(
"1960" = 0.7,
"1980" = 0.7,
"2000" = 0.7,
"2020" = 1.5
)
col_bg <- colors$palette[["bg"]]
col_text <- colors$palette[["text"]]
col_grid <- colors$palette[["grid"]]
col_2020 <- colors$palette[["2020"]]
### |- titles and caption ----
title_text <- "The Planet Still Breathes"
subtitle_text <- str_glue(
"Decade-averaged CO₂ seasonal cycle at Mauna Loa, 1960s–2020s.<br>",
"Each curve shows how atmospheric CO₂ rises and falls across the year — ",
"driven by Northern Hemisphere plant growth and decay.<br>",
"**+{co2_2020s - co2_1960s} ppm higher baseline since the 1960s** ",
"(~{co2_1960s} ppm to ~{co2_2020s} ppm)"
)
caption_text <- create_dcc_caption(
dcc_year = 2026,
dcc_day = 23,
source_text = "NOAA Global Monitoring Laboratory · Mauna Loa CO₂ monthly means"
)
### |- x-axis month labels ----
month_labels <- month.abb # "Jan" … "Dec"
### |- direct end-line labels — 1960s and 2020s only ----
label_data <- co2_waveform |>
group_by(decade) |>
slice_max(month, n = 1) |>
ungroup() |>
filter(decade %in% c(1960, 2020)) |>
mutate(label = glue("{decade}s"))
### |- fonts ----
setup_fonts()
fonts <- get_font_families()
### |- base and weekly theme ----
base_theme <- create_base_theme(colors)
weekly_theme <- extend_weekly_theme(
base_theme,
theme(
# background
plot.background = element_rect(fill = col_bg, color = NA),
panel.background = element_rect(fill = col_bg, color = NA),
# grid — horizontal reference lines only; vertical absent
panel.grid.major.y = element_line(color = col_grid, linewidth = 0.3),
panel.grid.major.x = element_blank(),
panel.grid.minor = element_blank(),
# axes
axis.ticks = element_blank(),
axis.text.x = element_text(
family = fonts$text, size = 9, color = col_text, margin = margin(t = 4)
),
axis.text.y = element_text(
family = fonts$text, size = 9, color = col_text
),
axis.title.x = element_blank(),
axis.title.y = element_text(
family = fonts$text, size = 9.5, color = col_text,
margin = margin(r = 8), angle = 90
),
# text hierarchy
plot.title = element_text(
family = fonts$title, size = 28, face = "bold",
color = col_text, margin = margin(b = 6)
),
plot.subtitle = element_markdown(
family = fonts$text, size = 10, color = col_text,
lineheight = 1.45, margin = margin(b = 20)
),
plot.caption = element_markdown(
family = fonts$text, size = 7.5, color = "#888888",
hjust = 0, margin = margin(t = 16)
),
# margins
plot.margin = margin(t = 20, r = 60, b = 12, l = 20)
)
)
theme_set(weekly_theme)
```
#### [6. Plot]{.smallcaps}
```{r}
#| label: plot
#| warning: false
### |- main plot ----
p <- ggplot() +
# Geoms
geom_hline(yintercept = 0, color = col_grid, linewidth = 0.6) +
geom_line(
data = co2_context,
aes(x = month, y = mean_anomaly, color = decade_fct, group = decade_fct),
linewidth = 0.7, lineend = "round", linejoin = "round"
) +
geom_line(
data = co2_hero,
aes(x = month, y = mean_anomaly, group = decade_fct),
color = col_2020,
linewidth = 1.6, lineend = "round", linejoin = "round"
) +
geom_text(
data = label_data,
aes(x = month + 0.15, y = mean_anomaly, label = label, color = decade_fct),
hjust = 0,
size = 3.2,
family = fonts$text,
fontface = "bold",
inherit.aes = FALSE
) +
# Annotate
annotate(
geom = "richtext",
x = 6.2,
y = 3.9,
label = glue(
"<b>Same seasonal breath (~6–8 ppm)</b><br>",
"Amplitude stable across all 4 decades"
),
hjust = 0,
vjust = 1,
size = 3.3,
family = fonts$text,
color = col_text,
fill = NA,
label.color = NA,
lineheight = 1.4
) +
# Scales
scale_x_continuous(
breaks = 1:12,
labels = month_labels,
expand = expansion(mult = c(0.02, 0.12))
) +
scale_y_continuous(
breaks = seq(-4, 4, by = 2),
labels = function(x) glue("{x} ppm"),
limits = c(-4.2, 4.2)
) +
scale_color_manual(values = decade_colors) +
guides(color = "none") +
coord_cartesian(clip = "off") +
# Labs
labs(
title = title_text,
subtitle = subtitle_text,
caption = caption_text,
y = "Seasonal anomaly (CO₂ ppm, deviation from trend)",
color = NULL
)
```
#### [7. Save]{.smallcaps}
```{r}
#| label: save
#| warning: false
### |- plot image ----
save_plot(
p,
type = "30daychartchallenge",
year = 2026,
day = 23,
width = 10,
height = 8
)
```
#### [8. Session Info]{.smallcaps}
::: {.callout-tip collapse="true"}
##### Expand for Session Info
```{r, echo = FALSE}
#| eval: true
#| warning: false
sessionInfo()
```
:::
#### [9. GitHub Repository]{.smallcaps}
::: {.callout-tip collapse="true"}
##### Expand for GitHub Repo
The complete code for this analysis is available in [`30dcc_2026_23.qmd`](https://github.com/poncest/personal-website/blob/master/data_visualizations/TidyTuesday/2026/30dcc_2026_23.qmd).
For the full repository, [click here](https://github.com/poncest/personal-website/).
:::
#### [10. References]{.smallcaps}
::: {.callout-tip collapse="true"}
##### Expand for References
1. **Data Sources:**
- Thoning, K.W., Crotwell, A.M., and Mund, J.W. (2025).
Atmospheric Carbon Dioxide Dry Air Mole Fractions from continuous
measurements at Mauna Loa, Hawaii. National Oceanic and Atmospheric
Administration (NOAA), Global Monitoring Laboratory (GML),
Boulder, Colorado, USA.
https://doi.org/10.15138/yaf1-bk21
Retrieved from: https://gml.noaa.gov/aftp/ccg/co2/trends/co2_mm_mlo.csv
:::
#### [11. Custom Functions Documentation]{.smallcaps}
::: {.callout-note collapse="true"}
##### 📦 Custom Helper Functions
This analysis uses custom functions from my personal module library for efficiency and consistency across projects.
**Functions Used:**
- **`fonts.R`**: `setup_fonts()`, `get_font_families()` - Font management with showtext
- **`social_icons.R`**: `create_social_caption()` - Generates formatted social media captions
- **`image_utils.R`**: `save_plot()` - Consistent plot saving with naming conventions
- **`base_theme.R`**: `create_base_theme()`, `extend_weekly_theme()`, `get_theme_colors()` - Custom ggplot2 themes
**Why custom functions?**\
These utilities standardize theming, fonts, and output across all my data visualizations. The core analysis (data tidying and visualization logic) uses only standard tidyverse packages.
**Source Code:**\
View all custom functions → [GitHub: R/utils](https://github.com/poncest/personal-website/tree/master/R)
:::