# 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 # ============================= # UI # ============================= ui <- dashboardPage( skin = "black", 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;" ) ) ), 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" ) ), # ---- Minimal "Share" button HTML + JS inlined ---- tags$div( style = "text-align: left; margin: 1em 0 1em 2em;", HTML(' '), tags$script( HTML(" (function() { const shareBtn = document.getElementById('share-button'); // Reusable helper function to show a small “Copied!” message function showCopyNotification() { const notification = document.createElement('div'); notification.innerText = 'Copied to clipboard'; notification.style.position = 'fixed'; notification.style.bottom = '20px'; notification.style.right = '20px'; notification.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; notification.style.color = '#fff'; notification.style.padding = '8px 12px'; notification.style.borderRadius = '4px'; notification.style.zIndex = '9999'; document.body.appendChild(notification); setTimeout(() => { notification.remove(); }, 2000); } shareBtn.addEventListener('click', function() { const currentURL = window.location.href; const pageTitle = document.title || 'Check this out!'; // If browser supports Web Share API if (navigator.share) { navigator.share({ title: pageTitle, text: '', url: currentURL }) .catch((error) => { console.log('Sharing failed', error); }); } else { // Fallback: Copy URL if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(currentURL).then(() => { showCopyNotification(); }, (err) => { console.error('Could not copy text: ', err); }); } else { // Double fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = currentURL; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); showCopyNotification(); } catch (err) { alert('Please copy this link:\\n' + currentURL); } document.body.removeChild(textArea); } } }); })(); ") ) ) # ---- End: Minimal Share button snippet ---- ), dashboardBody( tags$head( tags$link( href = "https://fonts.googleapis.com/css2?family=OCR+A+Extended&display=swap", rel = "stylesheet" ), tags$style(HTML(" /* Force OCR A Extended font across the entire UI and all HTML elements */ 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; } /* Header gradient background */ .main-header .navbar { background: linear-gradient(to right, #3b6978, #204051) !important; } /* Logo area (left corner of the header) */ .main-header .logo { background: #1b2a2f !important; color: #ffffff !important; border-bottom: none; font-size: 18px; font-weight: 600; } /* Sidebar background */ .main-sidebar { background-color: #1b2a2f !important; } /* Active or hovered tab in the sidebar */ .sidebar-menu > li.active > a, .sidebar-menu > li:hover > a { background-color: #344e5c !important; border-left-color: #78cdd7 !important; color: #ffffff !important; } /* Sidebar menu item icons */ .sidebar-menu .fa { color: #78cdd7 !important; } /* Sidebar menu item text */ .sidebar-menu > li > a { color: #b8c7ce !important; font-size: 15px; font-weight: 500; } /* Customize the boxes */ .box { border-top: none !important; box-shadow: 0 4px 8px rgba(0,0,0,0.1); border-radius: 6px; } .box.box-solid > .box-header { background-color: #204051; color: #fff; border-radius: 6px 6px 0 0; } /* Plot box spacing */ .box .box-body { padding: 0 !important; } /* Footer text styling (plot captions, etc.) */ .small, small { font-size: 75%; } /* Responsive map container */ .map-container { height: 70vh; width: 100%; } @media (max-width: 768px) { .map-container { height: 50vh; } } ")) ), 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;" # Scroll if content overflows ) ) ), fluidRow( column( width = 9, box( width = NULL, title = strong("Citation"), solidHeader = TRUE, tags$p( "John Gerring, Alan Hicken, 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 = "viridis", domain = plot_data[[index_col]], na.color = "white" ) # Construct a label/popup-like text labels <- sprintf( "Country: %s
%s Index: %s", plot_data$Country, index_col, ifelse(is.na(plot_data[[index_col]]), "N/A", plot_data[[index_col]]) ) %>% lapply(htmltools::HTML) leafletProxy("cartogramPlot", data = plot_data) %>% clearShapes() %>% # clear existing polygons clearControls() %>% # clear existing legends addPolygons( fillColor = ~pal(get(index_col)), fillOpacity = 0.7, color = "grey20", 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)