---
title: "One future does not exist"
subtitle: "UN population projections show how fertility assumptions shape long-run outcomes. The shaded bands are not noise — they are the model's uncertainty made visible."
description: "Fan chart showing UN World Population Prospects 2024 low, medium, and high fertility variants from 1950 to 2100. Built with ggplot2 in R, the chart layers 80% and 95% uncertainty intervals over a median projection to show how fertility assumptions compound into vastly different population futures."
date: "2026-04-28"
author:
- name: "Steven Ponce"
url: "https://stevenponce.netlify.app"
citation:
url: "https://stevenponce.netlify.app/data_visualizations/30DayChartChallenge/2026/30dcc_2026_28.html"
categories: ["30DayChartChallenge", "2026"]
tags: [
"30DayChartChallenge",
"Uncertainties",
"Modeling",
"Fan Chart",
"Population",
"Uncertainty Intervals",
"UN WPP",
"Demography",
"Forecasting",
"Scenario Analysis",
"ggplot2",
"wpp2024"
]
image: "thumbnails/30dcc_2026_28.png"
format:
html:
toc: true
toc-depth: 5
code-link: true
code-fold: true
code-tools: true
code-summary: "Show code"
theme:
light: [flatly, assets/styling/custom_styles.scss]
dark: [darkly, assets/styling/custom_styles_dark.scss]
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, here
)
})
### |- 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
# Uncomment to install (large package — ~200MB; set timeout first):
# options(timeout = 600)
# devtools::install_github("PPgp/wpp2024")
#library(wpp2024)
### |- load datasets from wpp2024 package and cache (first run only) ----
# data("popproj1dt") # annual projections 2024–2100 with uncertainty bands
# data("pop1dt") # historical annual estimates 1950–2023
#
# pop_hist_cache <- here::here("2026/data/wpp2024_pop_historical.csv")
# pop_proj_cache <- here::here("2026/data/wpp2024_pop_projections.csv")
#
# if (!file.exists(pop_hist_cache)) {
# write_csv(as_tibble(pop1dt), pop_hist_cache)
# write_csv(as_tibble(popproj1dt), pop_proj_cache)
# }
### |- read from cache ----
pop_hist_raw <- read_csv(
here::here("data/30DayChartChallenge/2026/wpp2024_pop_historical.csv"),
show_col_types = FALSE
)
pop_proj_raw <- read_csv(
here::here("data/30DayChartChallenge/2026/wpp2024_pop_projections.csv"),
show_col_types = FALSE
)
```
#### [3. Examine the Data]{.smallcaps}
```{r}
#| label: examine
#| include: true
#| eval: true
#| results: 'hide'
#| warning: false
glimpse(pop_hist_raw)
glimpse(pop_proj_raw)
pop_hist_raw |> filter(name == "World") |> slice_tail(n = 5)
pop_proj_raw |> filter(name == "World") |> slice_head(n = 5)
pop_proj_raw |> names()
```
#### [4. Tidy Data]{.smallcaps}
```{r}
#| label: tidy
#| warning: false
### |- historical series (global, 1950–2023) ----
pop_hist <- pop_hist_raw |>
filter(name == "World") |>
select(year, pop_total = pop) |>
mutate(
pop_total = as.numeric(pop_total),
pop_bn = pop_total / 1e6, # thousands to billions
series_type = "historical"
)
### |- projection series (global, 2024–2100) ----
pop_proj <- pop_proj_raw |>
filter(name == "World") |>
select(
year,
pop_med = pop,
pop_80l, pop_80u,
pop_95l, pop_95u,
pop_low, pop_high
) |>
mutate(across(where(is.character), as.numeric)) |>
mutate(across(c(
pop_med, pop_80l, pop_80u, pop_95l, pop_95u,
pop_low, pop_high
), \(x) x / 1e6))
### |- anchor point: last historical observation spliced to projection ----
anchor_year <- 2024L
anchor_val <- pop_hist |>
filter(year == max(year)) |>
pull(pop_bn)
### |- splice: add the 2023 anchor as first row of projection for clean joins ----
anchor_row <- tibble(
year = max(pop_hist$year),
pop_med = anchor_val,
pop_80l = anchor_val,
pop_80u = anchor_val,
pop_95l = anchor_val,
pop_95u = anchor_val,
pop_low = anchor_val,
pop_high = anchor_val
)
pop_proj <- bind_rows(anchor_row, pop_proj)
### |- end-label data: values at 2100 ----
labels_2100 <- pop_proj |>
filter(year == 2100) |>
select(year, pop_med, pop_low, pop_high, pop_95l, pop_95u) |>
pivot_longer(
cols = c(pop_med, pop_low, pop_high, pop_95l, pop_95u),
names_to = "variant",
values_to = "pop_bn"
) |>
mutate(
label = case_when(
variant == "pop_high" ~ glue("{round(pop_bn, 1)}B\nHigh variant"),
variant == "pop_med" ~ glue("{round(pop_bn, 1)}B\nMedian"),
variant == "pop_low" ~ glue("{round(pop_bn, 1)}B\nLow variant"),
TRUE ~ NA_character_
)
) |>
filter(!is.na(label))
### |- seam annotation position ----
seam_year <- max(pop_hist$year)
seam_pop <- anchor_val
```
#### [5. Visualization Parameters]{.smallcaps}
```{r}
#| label: params
#| include: true
#| warning: false
### |- plot aesthetics ----
colors <- get_theme_colors(
palette = list(
"bg" = "#F5F3EE",
"hero" = "#8B1A2A",
"band_95" = "#D4A4AC",
"band_80" = "#B85C6A",
"variant_lo" = "#8B1A2A",
"variant_hi" = "#8B1A2A",
"hist" = "#3A3635",
"seam" = "#8B1A2A",
"annotation" = "#5C4033",
"grid" = "#E8E4DC"
)
)
### |- pre-extract scalar color values ----
col_bg <- colors$palette$bg
col_hero <- colors$palette$hero
col_band_95 <- colors$palette$band_95
col_band_80 <- colors$palette$band_80
col_hist <- colors$palette$hist
col_seam <- colors$palette$seam
col_annotation <- colors$palette$annotation
col_grid <- colors$palette$grid
### |- titles and caption ----
title_text <- str_glue("One future does not exist")
subtitle_text <- str_glue(
"UN population projections show how fertility assumptions shape long-run outcomes. ",
"The **shaded bands**<br>",
"are not noise — they are the model's uncertainty made visible."
)
caption_text <- create_dcc_caption(
dcc_year = 2026,
dcc_day = 28,
source_text = "United Nations, DESA, Population Division · World Population Prospects 2024"
)
### |- fonts ----
setup_fonts()
fonts <- get_font_families()
### |- plot theme ----
base_theme <- create_base_theme(colors)
weekly_theme <- extend_weekly_theme(
base_theme,
theme(
# canvas
plot.background = element_rect(fill = col_bg, color = NA),
panel.background = element_rect(fill = col_bg, color = NA),
# grid
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 = element_text(
family = fonts$text, size = 9,
color = col_annotation
),
axis.title.x = element_blank(),
axis.title.y = element_text(
family = fonts$text, size = 9,
color = col_annotation, angle = 90,
margin = margin(r = 6)
),
# text
plot.title = element_markdown(
family = fonts$title, size = 22,
face = "bold", color = "#1A0A0C",
margin = margin(b = 6)
),
plot.subtitle = element_markdown(
family = fonts$text, size = 10,
color = col_annotation, lineheight = 1.4,
margin = margin(b = 16)
),
plot.caption = element_markdown(
family = fonts$text, size = 7.5,
color = col_annotation, hjust = 0,
margin = margin(t = 12)
),
plot.margin = margin(t = 20, r = 70, b = 12, l = 16),
legend.position = "none"
)
)
theme_set(weekly_theme)
```
#### [6. Plot]{.smallcaps}
```{r}
#| label: plot
#| warning: false
### |- main plot ----
p <- ggplot() +
# Geoms
geom_ribbon(
data = pop_proj,
aes(x = year, ymin = pop_95l, ymax = pop_95u),
fill = col_band_95,
alpha = 0.18
) +
geom_ribbon(
data = pop_proj,
aes(x = year, ymin = pop_80l, ymax = pop_80u),
fill = col_band_80,
alpha = 0.38
) +
geom_line(
data = pop_proj,
aes(x = year, y = pop_high),
color = col_hero,
linetype = "22",
linewidth = 0.5,
alpha = 0.65
) +
geom_line(
data = pop_proj,
aes(x = year, y = pop_low),
color = col_hero,
linetype = "22",
linewidth = 0.5,
alpha = 0.65
) +
geom_line(
data = pop_proj,
aes(x = year, y = pop_med),
color = col_hero,
linewidth = 1.3
) +
geom_line(
data = pop_hist,
aes(x = year, y = pop_bn),
color = col_hist,
linewidth = 0.85
) +
geom_vline(
xintercept = seam_year,
color = col_seam,
linetype = "dashed",
linewidth = 0.45,
alpha = 0.7
) +
geom_point(
data = tibble(year = seam_year, pop_bn = seam_pop),
aes(x = year, y = pop_bn),
color = col_seam,
fill = col_bg,
shape = 21,
size = 2.8,
stroke = 1.2
) +
# Annote
annotate(
"text",
x = seam_year - 1,
y = 11.2,
label = "← Observed",
hjust = 1,
size = 3.0,
family = 'sans',
color = col_annotation,
fontface = "italic"
) +
annotate(
"text",
x = seam_year + 1,
y = 11.2,
label = "Projected →",
hjust = 0,
size = 3.0,
family = 'sans',
color = col_hero,
fontface = "italic"
) +
annotate(
"text",
x = 2060,
y = 7.4,
label = "80% uncertainty interval",
hjust = 0.5,
size = 2.8,
family = fonts$text,
color = col_annotation
) +
annotate(
"text",
x = 2060,
y = 7.0,
label = "95% uncertainty interval",
hjust = 0.5,
size = 2.8,
family = fonts$text,
color = col_annotation,
alpha = 0.7
) +
annotate(
"text",
x = 2047,
y = 12.6,
label = "Divergence driven\nby fertility assumptions",
hjust = 0.5,
size = 2.7,
family = fonts$text,
color = col_annotation,
fontface = "italic",
lineheight = 0.9
) +
geom_text(
data = labels_2100,
aes(x = 2100, y = pop_bn, label = label),
hjust = -0.08,
size = 2.9,
family = fonts$text,
color = col_hero,
lineheight = 0.9
) +
# Scales
scale_x_continuous(
breaks = seq(1950, 2100, by = 25),
expand = expansion(mult = c(0.01, 0.02))
) +
scale_y_continuous(
name = "Global population (billions)",
breaks = seq(2, 16, by = 2),
labels = \(x) paste0(x, "B"),
limits = c(2, 16.5),
expand = expansion(mult = c(0.01, 0.02))
) +
coord_cartesian(clip = "off") +
# Labs
labs(
title = title_text,
subtitle = subtitle_text,
caption = caption_text
)
```
#### [7. Save]{.smallcaps}
```{r}
#| label: save
#| warning: false
### |- plot image ----
save_plot(
p,
type = "30daychartchallenge",
year = 2026,
day = 28,
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_28.qmd`](https://github.com/poncest/personal-website/blob/master/data_visualizations/TidyTuesday/2026/30dcc_2026_28.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:**
- United Nations, Department of Economic and Social Affairs, Population Division (2024).
*World Population Prospects 2024.* Retrieved from: https://population.un.org/wpp/
- Sevcikova, H., & Raftery, A. (2024). *wpp2024: World Population Prospects 2024.*
R package. Retrieved from: https://github.com/PPgp/wpp2024
License: CC BY 3.0 IGO
2. **Key Datasets:**
- `pop1dt` — Annual population estimates, both sexes, 1950–2023 (thousands)
- `popproj1dt` — Annual population projections, 2024–2100, with median, 80% and 95%
uncertainty intervals, and low/high fertility variants (thousands)
:::
#### [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)
:::