An alluvial plot for Changing Markets for Russian Oil

Post-2014 and 2022: European decline, Asian growth, but lower total exports.

Data Visualization
Gecomputation
Geopolitics
{ggalluvial}
Sankey Diagram
Alluvial Plot
Author

Aditya Dahiya

Published

August 5, 2025

About the Data

The trade data presented in this analysis was sourced from the Observatory of Economic Complexity (OEC), a leading platform for visualizing and analyzing international trade data. The dataset specifically utilizes the BACI (Base pour l’Analyse du Commerce International) database, which is maintained by CEPII (Centre d’Études Prospectives et d’Informations Internationales). The data covers textile woven fabric exports (HS4 code: 52709) from European Union countries to various importing nations from 2010 to 2023. BACI provides harmonized bilateral trade flows at the product level, offering comprehensive coverage of world merchandise trade with high-quality data reconciliation procedures. The data was accessed through the OEC’s Tesseract API, which provides programmatic access to their extensive trade databases.

Figure 1: This alluvial diagram displays the flow of Russian oil exports to major importing countries from 2010 to 2023. The horizontal axis represents years, while the vertical axis shows trade values in billions of US dollars. Each colored band (alluvium) represents a specific importing country, with band width proportional to import volume. The flowing streams illustrate how trade relationships evolved over time, with countries maintaining, gaining, or losing market share. Vertical dashed lines mark key geopolitical events in 2014 and 2022 for temporal reference.

Data Acquisition from OEC API

The trade data was programmatically retrieved from the Observatory of Economic Complexity’s Tesseract API using a loop-based approach developed with assistance from Claude Sonnet 4 and Google Gemini 2.5. The technique employed {httr} for making GET requests and {jsonlite} for parsing JSON responses, iterating through years 2010-2023 to fetch textile woven fabric exports (HS4 code: 52709) from EU countries. Each API call was constructed by dynamically replacing a year placeholder in the base URL, with error handling to check HTTP status codes and a respectful 0.5-second delay between requests using Sys.sleep(). The yearly datasets were stored in a list and combined using bind_rows() from {dplyr}, with column names cleaned using rename_with() and regular expressions to ensure consistency across the final consolidated dataset.

Code
# Load required libraries
library(httr)
library(jsonlite)
library(tibble)
library(dplyr)

# Define the base URL (without year parameter)
base_url <- "https://api-v2.oec.world/tesseract/data.jsonrecords?cube=trade_i_baci_a_92&drilldowns=Importer+Country&include=Exporter+Country:eurus;Year:YEAR_PLACEHOLDER;HS4:52709&locale=en&parents=true&measures=Trade+Value"

# Define years to fetch
years <- 2010:2023

# Initialize empty list to store data from each year
yearly_data_list <- list()

# Loop through each year
for (year in years) {
  cat("Fetching data for year:", year, "\n")
  
  # Create URL for current year
  current_url <- gsub("YEAR_PLACEHOLDER", year, base_url)
  
  # Make the API request
  response <- GET(current_url)
  
  # Check if the request was successful
  if (status_code(response) == 200) {
    # Parse the JSON content
    json_content <- content(response, "text", encoding = "UTF-8")
    
    # Convert JSON to R data structure
    data_list <- fromJSON(json_content, flatten = TRUE)
    
    # Extract the data records
    if (is.list(data_list) && "data" %in% names(data_list)) {
      year_data <- as_tibble(data_list$data)
    } else if (is.data.frame(data_list)) {
      year_data <- as_tibble(data_list)
    } else {
      # If it's a list of records, convert directly
      year_data <- as_tibble(data_list)
    }
    
    # Add year column
    year_data$Year <- year
    
    # Store in list
    yearly_data_list[[as.character(year)]] <- year_data
    
    cat("Successfully fetched", nrow(year_data), "records for", year, "\n")
    
  } else {
    cat("Error fetching data for year", year, ": HTTP status code", status_code(response), "\n")
    cat("Response content:", content(response, "text"), "\n")
  }
  
  # Add a small delay between requests to be respectful to the API
  Sys.sleep(0.5)
}

# Combine all yearly data into one tibble
if (length(yearly_data_list) > 0) {
  trade_data <- bind_rows(yearly_data_list)
  
  # Clean up column names (remove spaces, make lowercase)
  trade_data <- trade_data %>%
    rename_with(~ gsub(" ", "_", tolower(.x)))
  
  # Move Year column to the front
  trade_data <- trade_data %>%
    select(year, everything())
  
  # Display summary information
  cat("\n=== FINAL RESULTS ===\n")
  cat("Total records:", nrow(trade_data), "\n")
  cat("Years covered:", paste(sort(unique(trade_data$year)), collapse = ", "), "\n")
  cat("Columns:", ncol(trade_data), "\n")
  
  cat("\nColumn names:\n")
  print(names(trade_data))
  
  cat("\nFirst 6 rows:\n")
  print(head(trade_data))
  
  cat("\nData summary by year:\n")
  year_summary <- trade_data %>%
    group_by(year) %>%
    summarise(
      records = n(),
      .groups = 'drop'
    )
  print(year_summary)
  
} else {
  cat("No data was successfully fetched.\n")
}

# Clean workspace - keep only trade_data
rm(list = setdiff(ls(), "trade_data"))

Loading packages & defining Visualization Parameters

Code
pacman::p_load(
  ggalluvial,    # Alluvial diagrams and flow visualizations
  tidyverse,     # Data manipulation & visualization
  scales,        # Nice scales for ggplot2
  fontawesome,   # Icons display in ggplot2
  ggtext,        # Markdown text in ggplot2
  showtext,      # Display fonts in ggplot2
  patchwork,     # Composing plots
  ggalluvial     # Alluvial Plots in R with ggplot2
)

bts = 40 # Base Text Size
sysfonts::font_add_google("Saira", "title_font")
sysfonts::font_add_google("Saira Condensed", "body_font")
sysfonts::font_add_google("Saira Extra Condensed", "caption_font")
showtext::showtext_auto()
# A base Colour
bg_col <- "white"
seecolor::print_color(bg_col)

# Colour for highlighted text
text_hil <- "grey30"
seecolor::print_color(text_hil)

# Colour for the text
text_col <- "grey20"
seecolor::print_color(text_col)


# Caption stuff for the plot
sysfonts::font_add(
  family = "Font Awesome 6 Brands",
  regular = here::here("docs", "Font Awesome 6 Brands-Regular-400.otf")
)
github <- "&#xf09b"
github_username <- "aditya-dahiya"
xtwitter <- "&#xe61b"
xtwitter_username <- "@adityadahiyaias"
social_caption_1 <- glue::glue("<span style='font-family:\"Font Awesome 6 Brands\";'>{github};</span> <span style='color: {text_hil}'>{github_username}  </span>")
social_caption_2 <- glue::glue("<span style='font-family:\"Font Awesome 6 Brands\";'>{xtwitter};</span> <span style='color: {text_hil}'>{xtwitter_username}</span>")
plot_caption <- paste0(
  "**Data**:  Observatory of Economic Complexity",
  "  |  **Code:** ", 
  social_caption_1, 
  " |  **Graphics:** ", 
  social_caption_2
  )
rm(github, github_username, xtwitter, 
   xtwitter_username, social_caption_1, 
   social_caption_2)

Creating the Russian Oil Exports Area Chart

This stacked area chart was built using {ggplot2}’s geom_area() function to show cumulative trade values over time. The data preprocessing used {dplyr} and fct_lump_n() from {forcats} to identify the top 9 importing countries by trade value, consolidating smaller importers into an “Others” category. The visualization employed white borders between areas for visual separation, alpha transparency for subtle layering, and {paletteer}’s “basetheme::royal” palette. Scale formatting was handled by {scales} functions like label_number() with cut_short_scale() to display values in billions with dollar prefixes, while the legend was positioned inside the plot area using ggplot2’s legend.position.inside parameter.

Code
# Manually load previously saved data
trade_data <- read_csv("C:/Users/dradi/OneDrive/Desktop/trade_data.csv")

df1 <- trade_data |> 
  mutate(
    importer_country = fct(importer_country),
    importer_country = fct_lump_n(
      importer_country,
      n = 9,
      w = trade_value,
      other_level = "Others"
    )
  ) |> 
  select(importer_country, year, trade_value) |> 
  group_by(year, importer_country) |> 
  summarise(trade_value = sum(trade_value, na.rm = T)) |> 
  arrange(desc(trade_value)) |> 
  mutate(rank = row_number()) |> 
  ungroup() |> 
  arrange(year, rank)
  
g <- df1 |> 
  ggplot(
    aes(
      x = year, 
      y = trade_value,
      fill = importer_country
    )
  ) +
  geom_area(
    colour = "white",
    alpha = 0.7
  ) +
  guides(
    fill = guide_legend(
      nrow = 4
    )
  ) +
  # geom_text(
  #   data = df1 |> filter(year == 2023),
  #   mapping = aes(label = importer_country),
  #   size = 12,
  #   hjust = 0,
  #   nudge_x = 0.2
  # ) +
  paletteer::scale_fill_paletteer_d("basetheme::royal", direction = -1) +
  scale_y_continuous(
    labels = label_number(
      prefix = "$",
      suffix = " ",
      scale_cut = cut_short_scale(space = T)
    ),
    expand = expansion(c(0, 0.1))
  ) +
  scale_x_continuous(
    breaks = seq(2010, 2023, 2),
    expand = expansion(c(0, 0.05))
  )+
  labs(
    title = "Russian Oil Importers, as an area chart",
    fill = NULL,
    y = "Oil Imports (Billions, USD)",
    x = NULL
  ) +
  theme_minimal(
    base_family = "body_font",
    base_size = bts
  ) +
  theme(
    legend.position = "inside",
    legend.position.inside = c(1, 1),
    legend.justification = c(1, 1),
    axis.line = element_line(
      arrow = arrow(length = unit(5, "mm")),
      colour = "grey40",
      linewidth = 0.5
    ),
    panel.grid = element_line(
      colour = "grey50",
      linewidth = 0.3
    ),
    panel.grid.minor = element_line(
      colour = "grey70",
      linewidth = 0.1
    ),
    legend.text = element_text(margin = margin(0,0,0,1, "mm")),
    legend.key.spacing.x = unit(0, "mm")
  )
  
ggsave(
  plot = g,
  filename = here::here("projects", "images", 
                        "russia_oil_exports_2.png"),
  height = 2000 * 5 / 4,
  width = 2000,
  units = "px",
  bg = "grey95"
)
Figure 2: Stacked area chart showing cumulative Russian oil imports by top importing countries from 2010-2023, with each colored layer representing a different nation’s trade volume over time.

Creating the Russian Oil Exports Alluvial Plot

This alluvial diagram was created using R’s powerful {ggalluvial} package, which extends {ggplot2} to create flow visualizations that show how categorical data changes over time. The visualization process began with data manipulation using {dplyr} functions like fct_lump_n() from {forcats} to identify the top 9 importing countries by trade value, grouping smaller importers into an “Others” category. The core visualization leverages geom_alluvium() to create the flowing bands between years and geom_stratum() to define the categorical blocks at each time point. Country labels were strategically positioned using stat_stratum() with position_nudge() to place them at the start (2010) and end (2023) of the timeline, with text sizing based on trade values. Historical context was added through vertical dashed lines created with geom_segment() and rotated text annotations marking the 2014 Crimean annexation and 2022 Ukraine war. The color palette came from {paletteer}, while typography was enhanced using {showtext} to incorporate Google Fonts, and the overall aesthetic was refined with a custom theme_minimal() configuration that positioned legends, adjusted margins, and styled grid lines to create a clean, publication-ready visualization.

Code
# pacman::p_load(ggalluvial)

# Some testing code to learn from vignettes given by {ggalluvial}

# vignette(topic = "ggalluvial", package = "ggalluvial")
# data(Refugees, package = "alluvial")
# 
# Refugees |> 
#   as_tibble()
# 
# ggplot(
#   data = Refugees,
#   aes(x = year, y = refugees, alluvium = country)) +
#   geom_alluvium(aes(fill = country, colour = country),
#                 alpha = .75, decreasing = FALSE) +
#   scale_x_continuous(breaks = seq(2003, 2013, 2)) +
#   theme_bw()

plot_title <- "Changing Markets for Russian Oil"

plot_subtitle <- "Post-2014 and 2022, European demand fell significantly as Asian markets, led by China and India, partially offset declining global volumes." |> str_wrap(72)

str_view(plot_subtitle)

g <- df1 |> 
  ggplot(
    mapping = aes(
      x = year, 
      alluvium = importer_country,
      y = trade_value,
      stratum = importer_country,
      fill = importer_country
    )
  ) +
  geom_alluvium(
    alpha = 0.5,
    decreasing = FALSE
  ) +
  geom_stratum(
    colour = "transparent",
    alpha = 0.25,
    width = 0.15,
    linewidth = 0.2,
    decreasing = FALSE
  ) +
  stat_stratum(
    geom = "text",
    data = df1 |> filter(year == 2010),
    mapping = aes(label = importer_country, size = trade_value),
    decreasing = FALSE,
    colour = text_col,
    hjust = 1,
    position = position_nudge(x = -0.4),
    check_overlap = TRUE,
    family = "caption_font"
  ) +
  stat_stratum(
    geom = "text",
    data = df1 |> filter(year == 2023),
    mapping = aes(label = importer_country, size = trade_value),
    decreasing = FALSE,
    colour = text_col,
    hjust = 0,
    position = position_nudge(x = 0.4),
    check_overlap = TRUE,
    family = "caption_font"
  ) +
  scale_size_continuous(range = c(3, 12)) +
  scale_x_continuous(
    breaks = c(seq(2010, 2020, 3), 2021, 2023),
    expand = expansion(0.1)
  ) +
  scale_y_continuous(
    expand = expansion(0),
    labels = scales::label_number(
      prefix = "$ ",
      scale_cut = cut_short_scale(space = T)
    )
  ) +
  guides(
    fill = guide_legend(
      nrow = 3
    ),
    size = "none"
  ) +
  geom_segment(
    x = 2013.9, xend = 2013.9,
    y = 0, yend = 150e9,
    colour = text_col,
    linewidth = 0.3,
    linetype = 2
  ) +
  annotate(
    geom = "text",
    label = "Russian Annexation of Crimea (20 February, 2014)",
    x = 2013.9,
    y = 150e9,
    angle = 90,
    hjust = 1,
    vjust = -0.3,
    family = "body_font",
    size = bts * 0.4
  ) +
  geom_segment(
    x = 2021.9, xend = 2021.9,
    y = 0, yend = 150e9,
    colour = text_col,
    linewidth = 0.3,
    linetype = 2
  ) +
  annotate(
    geom = "text",
    label = "Start of Ukraine War (24 February, 2022)",
    x = 2021.9,
    y = 150e9,
    angle = 90,
    hjust = 1,
    vjust = -0.3,
    family = "body_font",
    size = bts * 0.4
  ) +
  labs(
    title = plot_title,
    subtitle = plot_subtitle,
    caption = plot_caption,
    fill = NULL,
    x = NULL,
    y = "Imports of Russian Oil (in Billions, US $)"
  ) +
  paletteer::scale_fill_paletteer_d("basetheme::royal", direction = -1) +
  coord_cartesian(
    clip = "off"
  ) +
  theme_minimal(
    base_family = "body_font",
    base_size = bts * 1.5
  ) +
  theme(
    
    legend.position = "inside",
    legend.position.inside = c(1, 1),
    legend.justification = c(1, 1),
    legend.direction = "horizontal",
    legend.margin = margin(0,0,0,0, "mm"),
    legend.box.margin = margin(0,0,0,0, "mm"),
    legend.key.spacing = unit(1, "mm"),
    legend.text = element_text(
      margin = margin(0,4,0,2, "mm"),
      size = bts * 0.8
    ),
    
    # Overall
    text = element_text(
      margin = margin(0,0,0,0, "mm"),
      colour = text_col,
      lineheight = 0.3
    ),
    
    # Labels and Strip Text
    plot.title = element_text(
      margin = margin(3,0,2,0, "mm"),
      hjust = 0.5,
      vjust = 0.5,
      colour = text_hil,
      size = 2.4 * bts,
      family = "body_font",
      face = "bold"
      ),
    plot.subtitle = element_text(
      margin = margin(0,0,2,0, "mm"),
      vjust = 0.5,
      colour = text_hil,
      size = 1.4 * bts,
      hjust = 0.5,
      family = "body_font",
      lineheight = 0.3
      ),
    plot.caption = element_textbox(
      margin = margin(5,0,5,0, "mm"),
      hjust = 0.5,
      halign = 0.5,
      colour = text_hil,
      size = bts * 0.7,
      family = "caption_font"
    ),
    plot.caption.position = "plot",
    plot.title.position = "plot",
    plot.margin = margin(3,3,3,3, "mm"),
    
    # Axes Lines, Ticks, Text and labels
    axis.ticks = element_blank(),
    axis.ticks.length = unit(0, "mm"),
    axis.text.x = element_text(
      margin = margin(1,1,1,1, "mm"),
      colour = text_hil,
      size = bts
    ),
    axis.text.y = element_text(
      margin = margin(1,1,1,1, "mm"),
      colour = text_hil,
      family = "caption_font",
      size = 0.75 * bts,
      vjust = 0
    ),
    axis.title.y = element_text(
      colour = text_hil,
      margin = margin(0,0,0,0, "mm"),
      size = bts * 1
    ),
    axis.title.x = element_text(
      colour = text_hil,
      margin = margin(2,2,2,2, "mm"),
      size = bts * 1.5
    ),
    panel.grid.minor.x = element_blank(),
    panel.grid.minor.y = element_blank(),
    panel.grid.major.y = element_line(
      colour = alpha(text_col, 0.9),
      linewidth = 0.2,
      linetype = 3
    ),
    panel.grid.major.x = element_line(
      colour = alpha(text_col, 0.4),
      linewidth = 0.2,
      linetype = 3
    )
  )

ggsave(
  plot = g,
  filename = here::here("projects", "images", 
                        "russia_oil_exports_1.png"),
  height = 2000 * 5 / 4,
  width = 2000,
  units = "px",
  bg = "grey95"
)

A landscape graphic

Code
ggsave(
  plot = g + scale_x_continuous(
    breaks = c(seq(2010, 2020, 3), 2021, 2023),
    expand = expansion(0.03)
  ),
  filename = here::here("projects", "images", 
                        "russia_oil_exports_3.png"),
  height = 2000 * 5 / 4,
  width = 2000 * 8 / 4,
  units = "px",
  bg = "grey95"
)

Savings the thumbnail for the webpage

Code
# Saving a thumbnail

library(magick)

# Saving a thumbnail for the webpage
image_read(here::here("projects", "images", 
                      "russia_oil_exports_3.png")) |> 
  image_resize(geometry = "x400") |> 
  image_write(
    here::here(
      "projects", 
      "images", 
      "russia_oil_exports.png"
    )
  )

Session Info

Code
pacman::p_load(
  ggalluvial,    # Alluvial diagrams and flow visualizations
  tidyverse,     # Data manipulation & visualization
  scales,        # Nice scales for ggplot2
  fontawesome,   # Icons display in ggplot2
  ggtext,        # Markdown text in ggplot2
  showtext,      # Display fonts in ggplot2
  patchwork,     # Composing plots
  ggalluvial     # Alluvial Plots in R with ggplot2
)

sessioninfo::session_info()$packages |> 
  as_tibble() |> 
  dplyr::select(package, 
         version = loadedversion, 
         date, source) |> 
  dplyr::arrange(package) |> 
  janitor::clean_names(
    case = "title"
  ) |> 
  gt::gt() |> 
  gt::opt_interactive(
    use_search = TRUE
  ) |> 
  gtExtras::gt_theme_espn()
Table 1: R Packages and their versions used in the creation of this page and graphics