Visualizing Cardinal Electors with {ggparliament} and {ggimage}
This visualization combines {ggparliament}’s circular layouts, {ggimage}’s portrait mapping, and a layered design crafted with {ggtext}, {scales}, and {patchwork}. Data wrangling was powered by {tidyverse}, with additional customization from {showtext} and {colorspace}
Geopolitics
Images
Web Scraping
{rvest}
{ggimage}
{ggparliament}
Author
Aditya Dahiya
Published
April 26, 2025
Who Will Choose the Next Pope?
Figure 1: This graphic shows the age distribution of the 134 cardinal electors for the 2025 papal conclave. Each dot represents a cardinal, placed according to their age. The colour of each dot indicates the cardinal’s continent — reflecting the dominance of Europe and the global nature of Roman Catholic Church. The visualization highlights the dynamics of geography which might come into play in the next Papal Conclave.
About the Data
The data for this visualization originates from the Wikipedia article Cardinal electors in the 2025 papal conclave, which provides a comprehensive and up-to-date list of the 135 cardinal electors eligible to participate in the upcoming conclave following the death of Pope Francis on April 21, 2025. This resource compiles information from official Vatican sources, including the Holy See Press Office and the Annuarium Statisticum Ecclesiae, and details each cardinal’s name, country, date of birth, ecclesiastical order (bishop, priest, or deacon), date of appointment (consistory), and the pope who appointed them. Notably, it also tracks changes in eligibility, such as Cardinal Antonio Cañizares Llovera’s decision not to attend due to health reasons, reducing the number of expected participants to 134. The dataset reflects the global composition of the College of Cardinals, with representation from 71 countries across six continents, and highlights that Pope Francis appointed 108 of the 135 electors, underscoring his significant influence on the Church’s future leadership. (Cardinal electors in the 2025 papal conclave)
How I made this graphic?
To create this visualization, I began by scraping data from Wikipedia using the {rvest} package’s read_html() and html_table() functions. After cleaning and wrangling the dataset with {tidyverse} tools like mutate() and {janitor}’s clean_names(), I calculated each cardinal’s age and organized them by continent. For the layout, I used the {ggparliament} package, especially its powerful parliament_data() function, to arrange the cardinals in a circular “parliament” style, grouped by continent and ordered by seniority. Portrait images of the cardinals were fetched using Google’s Custom Search API, processed with {magick} and {cropcircles} to create circular thumbnails. The final plot was crafted with {ggplot2}, where I used {ggimage}’s geom_image() to place each cardinal’s photo in the layout, enhanced the plot with customized fonts through {showtext}, markdown support via {ggtext}, and composed polished legends and captions. The colour palette was selected with {paletteer}. Careful attention was given to typography, accessibility, and design, resulting in a detailed and engaging visual story.
Loading required libraries
Code
pacman::p_load( tidyverse, # All things tidy scales, # Nice Scales for ggplot2 fontawesome, # Icons display in ggplot2 ggtext, # Markdown text support for ggplot2 showtext, # Display fonts in ggplot2 colorspace, # Lighten and Darken colours magick, # Download images and edit them ggimage, # Display images in ggplot2 patchwork, # Composing Plots rvest, # Web-Scraping ggbeeswarm, # Beeswarm Plots ggparliament # Parliament Layout computations)# URL of the Wikipedia page# Read the HTML content of the pagepage <-read_html("https://en.wikipedia.org/wiki/Cardinal_electors_in_the_2025_papal_conclave")# Extract the first table (which contains the list of cardinals)table_df <- page |>html_table(fill =TRUE)table_df <- table_df[[1]]rm(page)
Visualization Parameters
Code
# Font for titlesfont_add_google("Saira",family ="title_font") # Font for the captionfont_add_google("Saira Extra Condensed",family ="caption_font") # Font for plot textfont_add_google("Saira Condensed",family ="body_font") showtext_auto()# cols4all::c4a_gui()mypal <- paletteer::paletteer_d("NineteenEightyR::sunset2")[c(5,3,1)]# A base Colourbg_col <-"white"seecolor::print_color(bg_col)# Colour for highlighted texttext_hil <-"grey30"seecolor::print_color(text_hil)# Colour for the texttext_col <-"grey30"seecolor::print_color(text_col)line_col <-"grey30"# Define Base Text Sizebts <-90# Caption stuff for the plotsysfonts::font_add(family ="Font Awesome 6 Brands",regular = here::here("docs", "Font Awesome 6 Brands-Regular-400.otf"))github <-""github_username <-"aditya-dahiya"xtwitter <-""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:** Wikipedia", " | **Code:** ", social_caption_1, " | **Graphics:** ", social_caption_2 )rm(github, github_username, xtwitter, xtwitter_username, social_caption_1, social_caption_2)# Add text to plot-------------------------------------------------plot_title <-"The 134 cardinals who will elect the next Pope"|>str_wrap(15)str_view(plot_title)plot_subtitle <-"A circle of faith from every corner of the world, with Europe contributing the largest number of Cardinal electors."|>str_wrap(65)str_view(plot_subtitle)
Get temporary files on images of each Cardinal
Code
# Get a custom google search engine and API key# Tutorial: https://developers.google.com/custom-search/v1/overview# Tutorial 2: https://programmablesearchengine.google.com/# From:https://developers.google.com/custom-search/v1/overview# google_api_key <- "LOAD YOUR GOOGLE API KEY HERE"# From: https://programmablesearchengine.google.com/controlpanel/all# my_cx <- "GET YOUR CUSTOM SEARCH ENGINE ID HERE"# Improved function to download and save food imagesdownload_cardinal_potrait <-function(i) { google_api_key <- google_api_key my_cx <- my_cx# Build the API request URL with additional filters url <-paste0("https://www.googleapis.com/customsearch/v1?q=",URLencode(paste0("Cardinal ", cardinals$name[i], " photo")),"&cx=", my_cx,"&searchType=image","&key=", google_api_key,# "&imgSize=large", # Restrict to medium-sized images# "&imgType=photo","&num=1"# Fetch only one result )# Make the request response <- httr::GET(url)# if (response$status_code != 200) {# warning("Failed to fetch data for Cardinal: ", # cardinals$name[i])# return(NULL)# }# Parse the response result <- httr::content(response, "parsed")# Extract the image URL image_url <- result$items[[1]]$link# Process the image magick::image_read(image_url) |>image_resize("x300") |># Crop the image into a circle # (Credits: https://github.com/doehm/cropcircles) cropcircles::circle_crop(border_colour ="black",border_size =0 ) |>image_read() |>image_background(color ="transparent") |>image_resize("x300") |># Save or display the resultimage_write( here::here("data_vizs", paste0("temp_cardinals_", i, ".png") ) )}# Iterate and download imagesfor (i in1:nrow(cardinals)) {download_cardinal_potrait(i)}problem_numbers <-c(5, 6, 7, 16, 32, 36, 73)
Exploratory Data Analysis and Wrangling
Code
# Ensure the table is a tibblecardinals <-as_tibble(table_df) |> janitor::clean_names() |>mutate(# Extract the date string from raw databorn =str_extract(born, "^\\d{1,2} \\w+ \\d{4}"),# Convert to Date formatborn =dmy(born), # Calculate age for each cardinal in yearsage =time_length(interval(start = born, end =today()), unit ="years") |>floor() ) |>mutate(date_consistory =str_extract( consistory, "^\\d{1,2} \\w+ \\d{4}" ) |>dmy(),pope_consistory =str_remove( consistory, "^\\d{1,2} \\w+ \\d{4}" ) |>str_trim(),pope_consistory =paste0("Pope ", pope_consistory),.keep ="unused" ) |>select(-ref) |>mutate(country =str_remove_all(country, "\\[.*?\\]"),order =fct(order, levels =c("CB", "CP", "CD")) )plotdf2 <- cardinals |># Get ISO 3 code for countries, so that we can add continentsmutate(country =if_else(country =="Jerusalem", "Israel", country),iso3c = countrycode::countrycode( country,origin ="country.name.en",destination ="iso3c" ) ) |># Add continents based on countriesleft_join( rnaturalearth::ne_countries(returnclass ="sf") |> sf::st_drop_geometry() |>mutate(iso_a3 =if_else(name =="France", "FRA", iso_a3)) |>select(iso_a3, continent) |>rename(iso3c = iso_a3) ) |>mutate(continent =case_when( iso3c =="CPV"~"Europe", iso3c =="MLT"~"Europe", iso3c =="SGP"~"Asia", iso3c =="HKG"~"Asia", iso3c =="TON"~"Oceania",.default = continent ) ) |>#select(rank, name, country, iso3c, continent, born, order, pope_consistory) |> mutate(image_var =paste0("data_vizs/temp_cardinals_", rank, ".png"))# plotdf2 |> # count(continent, country)# Continents Datainteract_legend <- plotdf3 |>arrange(continent, country) |>mutate(label1 =paste0(name, " (", country,")")) |>group_by(continent) |>mutate(label2 =row_number()) |>summarise(label3 =paste0(label2, ". ", label1, collapse ="\n") )get_legend_data <-function(x){ interact_legend |>filter(continent = x) |>pull(label3)}
Getting a parliament layout
Code
# devtools::install_github("zmeers/ggparliament")# pacman::p_load_gh("zmeers/ggparliament")# The number of rows we want in the parliament layoutnumber_of_rows <-5# Continents count and their ordercontinent_counts_df <- plotdf2 |>count(continent, sort = T) continent_df <- continent_counts_df |>mutate(continent =fct( continent,levels = continent_counts_df$continent ) )# Improved plotdf2 for making cardinals in same orders: by continent and by rankplotdf3 <- plotdf2 |>mutate(continent =fct( continent,levels = continent_counts_df$continent ) ) |>arrange(continent, country, rank) |># An ID to link it up with ggpariament layoutmutate(id =row_number()) |># Add the parliament layout dataleft_join(# Computate the parliament layout of Cardinalsparliament_data(election_data = continent_df,# type = "semicircle",type ="circle",party_seats = continent_df$n,parl_rows = number_of_rows,plot_order = continent_df$continent ) |>as_tibble() |># Now this gives layout where senior ranked Cardinals are away from the well.# I need to computate straight-line (Euclidean) distance from centre of circle # (i.e. (0,0)) / semi-circle and get senior ranked caridnals near the well.mutate(depth =sqrt(x^2+ y^2),y_dist = y ) |>arrange(continent, depth, y_dist) |>mutate(id =row_number() ) )
# Saving a thumbnaillibrary(magick)# Saving a thumbnail for the webpageimage_read(here::here("data_vizs", "viz_papal_conclave_2025_parliament.png")) |>image_resize(geometry ="x400") |>image_write( here::here("data_vizs", "thumbnails", "viz_papal_conclave_2025_parliament.png" ) )
Session Info
Code
pacman::p_load( tidyverse, # All things tidy scales, # Nice Scales for ggplot2 fontawesome, # Icons display in ggplot2 ggtext, # Markdown text support for ggplot2 showtext, # Display fonts in ggplot2 colorspace, # Lighten and Darken colours magick, # Download images and edit them ggimage, # Display images in ggplot2 patchwork, # Composing Plots rvest, # Web-Scraping ggbeeswarm, # Beeswarm Plots ggparliament # Parliament Layout computations)sessioninfo::session_info()$packages |>as_tibble() |>select(package, version = loadedversion, date, source) |>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
Interactive Version
Code
pacman::p_load( tidyverse, # All things tidy scales, # Nice Scales for ggplot2 fontawesome, # Icons display in ggplot2 ggtext, # Markdown text support for ggplot2 showtext, # Display fonts in ggplot2 colorspace, # Lighten and Darken colours magick, # Download images and edit them ggimage, # Display images in ggplot2 patchwork, # Composing Plots rvest, # Web-Scraping ggbeeswarm, # Beeswarm Plots ggparliament, # Parliament Layout computations ggiraph # Interactive Visualization)plot_title <-"The 134 cardinals who will elect the next Pope"|>str_wrap(15)# A base Colourbg_col <-"white"# Colour for highlighted texttext_hil <-"grey30"# Colour for the texttext_col <-"grey30"line_col <-"grey30"# Load Data --------------------------------------------------# URL of the Wikipedia page# Read the HTML content of the pagepage <-read_html("https://en.wikipedia.org/wiki/Cardinal_electors_in_the_2025_papal_conclave")# Extract the first table (which contains the list of cardinals)table_df <- page |>html_table(fill =TRUE)table_df <- table_df[[1]]rm(page)# Data Wrangling --------------------------------------------# Ensure the table is a tibblecardinals <-as_tibble(table_df) |> janitor::clean_names() |>mutate(# Extract the date string from raw databorn =str_extract(born, "^\\d{1,2} \\w+ \\d{4}"),# Convert to Date formatborn =dmy(born), # Calculate age for each cardinal in yearsage =time_length(interval(start = born, end =today()), unit ="years") |>floor() ) |>mutate(date_consistory =str_extract( consistory, "^\\d{1,2} \\w+ \\d{4}" ) |>dmy(),pope_consistory =str_remove( consistory, "^\\d{1,2} \\w+ \\d{4}" ) |>str_trim(),pope_consistory =paste0("Pope ", pope_consistory),.keep ="unused" ) |>select(-ref) |>mutate(country =str_remove_all(country, "\\[.*?\\]"),order =fct(order, levels =c("CB", "CP", "CD")) )plotdf2 <- cardinals |># Get ISO 3 code for countries, so that we can add continentsmutate(country =if_else(country =="Jerusalem", "Israel", country),iso3c = countrycode::countrycode( country,origin ="country.name.en",destination ="iso3c" ) ) |># Add continents based on countriesleft_join( rnaturalearth::ne_countries(returnclass ="sf") |> sf::st_drop_geometry() |>mutate(iso_a3 =if_else(name =="France", "FRA", iso_a3)) |>select(iso_a3, continent) |>rename(iso3c = iso_a3) ) |>mutate(continent =case_when( iso3c =="CPV"~"Europe", iso3c =="MLT"~"Europe", iso3c =="SGP"~"Asia", iso3c =="HKG"~"Asia", iso3c =="TON"~"Oceania",.default = continent ) ) |>#select(rank, name, country, iso3c, continent, born, order, pope_consistory) |> mutate(image_var =paste0("data_vizs/temp_cardinals_", rank, ".png"))# plotdf2 |> # count(continent, country)# Parliament Layout -----------------------------------------------# The number of rows we want in the parliament layoutnumber_of_rows <-5# Continents count and their ordercontinent_counts_df <- plotdf2 |>count(continent, sort = T) continent_df <- continent_counts_df |>mutate(continent =fct( continent,levels = continent_counts_df$continent ) )# Improved plotdf2 for making cardinals in same orders: by continent and by rankplotdf3 <- plotdf2 |>mutate(continent =fct( continent,levels = continent_counts_df$continent ) ) |>arrange(continent, country, rank) |># An ID to link it up with ggpariament layoutmutate(id =row_number()) |># Add the parliament layout dataleft_join(# Computate the parliament layout of Cardinalsparliament_data(election_data = continent_df,# type = "semicircle",type ="circle",party_seats = continent_df$n,parl_rows = number_of_rows,plot_order = continent_df$continent ) |>as_tibble() |># Now this gives layout where senior ranked Cardinals are away from the well.# I need to computate straight-line (Euclidean) distance from centre of circle # (i.e. (0,0)) / semi-circle and get senior ranked caridnals near the well.mutate(depth =sqrt(x^2+ y^2),y_dist = y ) |>arrange(continent, depth, y_dist) |>mutate(id =row_number() ) )# Data for interactive Legend -------------------------------------# Continents Datainteract_legend <- plotdf3 |>arrange(continent, country) |>mutate(label1 =paste0(name, " (", country,")")) |>group_by(continent) |>mutate(label2 =row_number()) |>summarise(label3 =paste0(label2, ". ", label1, collapse ="\n") )get_legend_data <-function(x){ interact_legend |>filter(continent == x) |>pull(label3)}# VISUALIZATION ---------------------------------------------------bts =24mypal <- paletteer::paletteer_d("MoMAColors::ustwo", direction =-1 ) |>as.character()names(mypal) <- continent_df |>pull(continent)g <- plotdf3 |>ggplot(mapping =aes(x = x,y = y,data_id = id ) ) +geom_point_interactive(mapping =aes(colour = continent,tooltip =paste0("Name: ", name, "\n","Office: ", office, "\n","Country: ", country, "\n","Continent: ", continent, "\n","Age: ", age, " years\n","Rank / Seniority: ", rank ) ),size =22,pch =20 ) +annotate(geom ="text",label = plot_title,x =0,y =0,# family = "body_font",size = bts *0.5,hjust =0.5,vjust =0.5,lineheight =0.9,colour = text_hil,fontface ="bold" ) +scale_colour_manual_interactive(values = mypal,labels = continent_df |>mutate(label =paste0( continent, "<br>(**", n, "**)")) |>pull(label),data_id =function(x) x,tooltip = get_legend_data ) +coord_fixed(clip ="off" ) +labs(caption ="Data:Wikipedia | Code & Graphics: X @adityadahiyaias",colour ="The Continent which each Cardinal belongs to",y =NULL, x =NULL ) +theme_void(base_size = bts ) +theme(# Overallplot.margin =margin(5,0,2,0, "mm"),plot.title.position ="plot",text =element_text(colour = text_hil,hjust =0.5 ),# Labels and Strip Textplot.caption =element_textbox(hjust =0.5,colour = text_hil,size =0.6* bts,margin =margin(3,0,1,0, "lines") ),plot.caption.position ="plot",# Legendlegend.position ="bottom",legend.title.position ="top",legend.title =element_text(hjust =0.5,face ="bold" ),legend.text =element_textbox(hjust =0.1 ),legend.text.position ="right" )tooltip_css <-"background-color:white;color:black"# INTERACTIVITY ----------------------------------------------------girafe(ggobj = g,options =list(opts_tooltip(css = tooltip_css, opacity =0.9 ),opts_sizing(width = .7),opts_hover_inv(css ="opacity:0.25;"),opts_hover(css ="fill:white;stroke:black;"),opts_zoom(max =5) ))
An interactive visualization on Cardinal Electors for the Papal Conclave 2025: A circle of faith from every corner of the world, with Europe contributing the largest number.