Spaces:
Runtime error
Runtime error
| # 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(' | |
| <button id="share-button" | |
| style=" | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| padding: 5px 10px; | |
| font-size: 16px; | |
| font-weight: normal; | |
| color: #000; | |
| background-color: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| box-shadow: 0 1.5px 0 #000; | |
| "> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" | |
| stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="18" cy="5" r="3"></circle> | |
| <circle cx="6" cy="12" r="3"></circle> | |
| <circle cx="18" cy="19" r="3"></circle> | |
| <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> | |
| <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> | |
| </svg> | |
| <strong>Share</strong> | |
| </button> | |
| '), | |
| 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, 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 | |
| # 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( | |
| "<strong>Country:</strong> %s<br/>", | |
| "<strong>%sOverall:</strong> %s<br/>", | |
| "<strong>%sRepresentation Gap:</strong> %s<br/>", | |
| "<strong>%sEthnicity:</strong> %s<br/>", | |
| "<strong>%sGender:</strong> %s<br/>", | |
| "<strong>%sReligion:</strong> %s<br/>", | |
| "<strong>%sLanguage:</strong> %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.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("<p>Select a country by clicking on the map.</p>") | |
| } else { | |
| data <- selected_data() | |
| html_content <- paste0( | |
| "<b>Country:</b> ", data$Country, "<br/>", | |
| "<b>Overall:</b> ", ifelse(is.na(data$Overall), "N/A", data$Overall), "<br/>", | |
| "<b>Representation Gap:</b> ", ifelse(is.na(data$RepresentationGap), "N/A", data$RepresentationGap), "<br/>", | |
| "<b>Ethnicity:</b> ", ifelse(is.na(data$Ethnicity), "N/A", data$Ethnicity), "<br/>", | |
| "<b>Gender:</b> ", ifelse(is.na(data$Gender), "N/A", data$Gender), "<br/>", | |
| "<b>Religion:</b> ", ifelse(is.na(data$Religion), "N/A", data$Religion), "<br/>", | |
| "<b>Language:</b> ", ifelse(is.na(data$Language), "N/A", data$Language) | |
| ) | |
| HTML(html_content) | |
| } | |
| }) | |
| } | |
| # ============================= | |
| # Launch the Shiny App | |
| # ============================= | |
| shinyApp(ui = ui, server = server) | |