# setwd("~/Downloads") options(error = NULL) library(shiny) library(shinydashboard) library(dplyr) library(readr) library(sf) library(rnaturalearth) library(rnaturalearthdata) library(countrycode) library(leaflet) # <-- For OpenStreetMap-based map library(viridisLite) # ============================= # UI # ============================= ui <- dashboardPage( skin = "black", # ======================= # HEADER # ======================= dashboardHeader( title = span( style = "font-weight: 600; font-size: 13px;", a( href = "http://www.globalleadershipproject.net", "GlobalLeadershipProject.net", target = "_blank", style = "color: white; text-decoration: underline;" ) ) ), # ======================= # SIDEBAR # ======================= dashboardSidebar( sidebarMenu( menuItem("Map Type", tabName = "cartogramTab", icon = icon("globe")) ), div( style = "margin: 15px;", selectInput( inputId = "indexChoice", label = "Select Representation Index:", choices = c("Overall", "RepresentationGap", "Ethnicity", "Gender", "Religion", "Language"), selected = "Overall" ) ), # ---- “Share” button ---- tags$div( style = "text-align: left; margin: 1em 0 1em 2em;", HTML(' '), tags$script(HTML(" (function () { const shareBtn = document.getElementById('share-button'); function showCopyNotification () { const n = document.createElement('div'); n.innerText = 'Copied to clipboard'; n.style.position = 'fixed'; n.style.bottom = '20px'; n.style.right = '20px'; n.style.background = 'rgba(0,0,0,0.8)'; n.style.color = '#fff'; n.style.padding = '8px 12px'; n.style.borderRadius = '4px'; n.style.zIndex = '9999'; document.body.appendChild(n); setTimeout(() => n.remove(), 2000); } shareBtn.addEventListener('click', () => { const url = window.location.href; const title = document.title || 'Check this out!'; if (navigator.share) { navigator.share({ title, text: '', url }).catch(console.log); } else if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(showCopyNotification); } else { const ta = document.createElement('textarea'); ta.value = url; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); showCopyNotification(); } catch { alert('Please copy this link:\\n' + url); } document.body.removeChild(ta); } }); })(); ")) ) # ---- /Share button ---- ), # ======================= # BODY # ======================= dashboardBody( tags$head( tags$link( href = "https://fonts.googleapis.com/css2?family=OCR+A+Extended&display=swap", rel = "stylesheet" ), tags$style(HTML(" /* -------- Base typography & theme -------- */ html, body, h1, h2, h3, h4, h5, h6, p, div, span, label, input, button, select, .box, .content-wrapper, .main-sidebar, .main-header .navbar, .main-header .logo, .sidebar-menu, .sidebar-menu li a, .sidebar-menu .fa { font-family: 'OCR A Extended', monospace !important; } .main-header .navbar { background: linear-gradient(to right,#3b6978,#204051)!important; } .main-header .logo { background:#1b2a2f!important; color:#fff!important; border-bottom:none; font-size:18px; font-weight:600; } .main-sidebar { background-color:#1b2a2f!important; } .sidebar-menu>li.active>a, .sidebar-menu>li:hover>a { background:#344e5c!important; border-left-color:#78cdd7!important; color:#fff!important; } .sidebar-menu .fa { color:#78cdd7!important; } .sidebar-menu>li>a { color:#b8c7ce!important; font-size:15px; font-weight:500; } .box { border-top:none!important; box-shadow:0 4px 8px rgba(0,0,0,.1); border-radius:6px; } .box.box-solid>.box-header{ background:#204051; color:#fff; border-radius:6px 6px 0 0; } .box .box-body { padding:0!important; } .small, small { font-size:75%; } .map-container { height:70vh; width:100%; } @media (max-width:768px) { .map-container{height:50vh;} } /* -------- Mobile font bump -------- */ @media (max-width:768px){ html, body { font-size:18px!important; line-height:1.4!important; } .sidebar-menu>li>a { font-size:18px!important; } .sidebar-menu .fa { font-size:20px!important; } .main-header .logo { font-size:22px!important; } .box.box-solid>.box-header, .box>.box-header>.box-title { font-size:20px!important; } input, select, button { font-size:18px!important; } .leaflet-popup-content, .leaflet-tooltip { font-size:16px!important; } } ")) ), tabItem( tabName = "cartogramTab", fluidRow( column( width = 9, box( width = NULL, title = strong("Country-level Representation"), solidHeader = TRUE, div(class = "map-container", leafletOutput("cartogramPlot", width = "100%", height = "100%")) ) ), column( width = 3, box( width = NULL, title = strong("Selected Country Data"), solidHeader = TRUE, uiOutput("selectedCountryData"), style = "overflow-y: auto;" ) ) ), fluidRow( column( width = 9, box( width = NULL, title = strong("Citation"), solidHeader = TRUE, tags$p( "John Gerring, Connor T. Jerzak, Erzen Öncel. ", "The Composition of Descriptive Representation. ", em("American Political Science Review,"), " 118(2): 784–801, 2024. ", tags$a( href = "https://www.cambridge.org/core/services/aop-cambridge-core/content/view/7EAEA1CA4C553AB9D76054D1FA9C0840/S0003055423000680a.pdf/the-composition-of-descriptive-representation.pdf", "PDF", target = "_blank" ), " | ", tags$a( href = "https://connorjerzak.com/wp-content/uploads/2024/07/CompositionBib.txt", "BibTeX", target = "_blank" ), " | ", tags$a( href = "https://www.youtube.com/watch?v=nnfDj1NdOMo", "YouTube", target = "_blank" ) ) ) ) ) ) ) ) # ============================= # SERVER # ============================= server <- function(input, output, session) { # 1. Custom matches for countries not recognized by default in 'countrycode' custom_iso_matches <- c("Kosovo" = "XKX", "Somaliland" = "SOM") # or any valid code you prefer # 2. Read CSV data and create ISO3 codes with custom matches rankings_data <- reactive({ read_csv("CountryRepresentationRankings.csv", show_col_types = FALSE) %>% mutate(iso_a3 = countrycode( sourcevar = Country, origin = "country.name", destination = "iso3c", custom_match = custom_iso_matches )) }) # 3. Read/prepare world map shapefile (still from Natural Earth), # but transform to lat/lon (EPSG:4326) to align with Leaflet. world_sf <- reactive({ ne_countries(scale = "medium", returnclass = "sf") %>% dplyr::select(name, iso_a3, pop_est, geometry) %>% st_transform(crs = 4326) # Leaflet requires lat/lon }) # 4. Create the joined sf object cartogram_sf <- reactive({ merged_sf <- world_sf() %>% left_join(rankings_data(), by = "iso_a3") # Filter out countries with no data in "Overall" merged_sf[!is.na(merged_sf$Overall), ] }) # 5. Create the Leaflet map with OSM tiles # and dynamically add polygons based on index choice. # Initialize the leaflet map (empty) so it renders once: output$cartogramPlot <- renderLeaflet({ leaflet() %>% addProviderTiles("OpenStreetMap.Mapnik") %>% setView(lng = 0, lat = 20, zoom = 2) # A broad global view }) # Observe changes in the chosen index and update polygons + legend observeEvent(input$indexChoice, { plot_data <- cartogram_sf() index_col <- input$indexChoice # Build a color palette based on the chosen index pal <- colorNumeric( palette = viridisLite::viridis(256, begin = 0, end = 0.80), domain = plot_data[[index_col]], na.color = "white" ) # Construct a label/popup-like text # Construct an indicator function for clarity starIfSelected <- function(colName) { if (index_col == colName) "*" else "" } # Build a label/popup to show all columns labels <- sprintf( paste0( "Country: %s
", "%sOverall: %s
", "%sRepresentation Gap: %s
", "%sEthnicity: %s
", "%sGender: %s
", "%sReligion: %s
", "%sLanguage: %s" ), # 1. Country ifelse(is.na(plot_data$Country), "N/A", plot_data$Country), # 2. Overall starIfSelected("Overall"), ifelse(is.na(plot_data$Overall), "N/A", plot_data$Overall), # 3. RepresentationGap starIfSelected("RepresentationGap"), ifelse(is.na(plot_data$RepresentationGap), "N/A", plot_data$RepresentationGap), # 4. Ethnicity starIfSelected("Ethnicity"), ifelse(is.na(plot_data$Ethnicity), "N/A", plot_data$Ethnicity), # 5. Gender starIfSelected("Gender"), ifelse(is.na(plot_data$Gender), "N/A", plot_data$Gender), # 6. Religion starIfSelected("Religion"), ifelse(is.na(plot_data$Religion), "N/A", plot_data$Religion), # 7. Language starIfSelected("Language"), ifelse(is.na(plot_data$Language), "N/A", plot_data$Language) ) %>% lapply(htmltools::HTML) leafletProxy("cartogramPlot", data = plot_data) %>% clearShapes() %>% # clear existing polygons clearControls() %>% # clear existing legends addPolygons( fillColor = ~pal(get(index_col)), fillOpacity = 0.95, color = "gray", weight = 0.4, layerId = ~iso_a3, label = labels, highlightOptions = highlightOptions( color = "white", weight = 2, bringToFront = TRUE ) ) %>% addLegend( position = "bottomright", pal = pal, bins = 5, values = plot_data[[index_col]], title = paste(index_col, "Index"), opacity = 1 ) }, ignoreNULL = FALSE) # Trigger once on startup too # Track which country was clicked selected_iso <- reactiveVal(NULL) observeEvent(input$cartogramPlot_shape_click, { click <- input$cartogramPlot_shape_click if (!is.null(click$id)) { selected_iso(click$id) } }) # Reactive to fetch the selected country's data selected_data <- reactive({ iso <- selected_iso() if (is.null(iso)) return(NULL) rankings_data() %>% filter(iso_a3 == iso) }) # Render the selected country data in the box output$selectedCountryData <- renderUI({ if (is.null(selected_data())) { HTML("

Select a country by clicking on the map.

") } else { data <- selected_data() html_content <- paste0( "Country: ", data$Country, "
", "Overall: ", ifelse(is.na(data$Overall), "N/A", data$Overall), "
", "Representation Gap: ", ifelse(is.na(data$RepresentationGap), "N/A", data$RepresentationGap), "
", "Ethnicity: ", ifelse(is.na(data$Ethnicity), "N/A", data$Ethnicity), "
", "Gender: ", ifelse(is.na(data$Gender), "N/A", data$Gender), "
", "Religion: ", ifelse(is.na(data$Religion), "N/A", data$Religion), "
", "Language: ", ifelse(is.na(data$Language), "N/A", data$Language) ) HTML(html_content) } }) } # ============================= # Launch the Shiny App # ============================= shinyApp(ui = ui, server = server)