# setwd("~/Downloads") { # app.R options(error = NULL) # ------------------------------ # 1. Load Packages # ------------------------------ library(shiny) library(shinydashboard) library(leaflet) library(raster) library(DT) library(readr) library(dplyr) # For data manipulation library(ggplot2) # For histogram library(RColorBrewer) library(sp) # For handling map clicks/extracting raster values # ------------------------------ # 2. Data & Config # ------------------------------ # Define time periods corresponding to each band in the GeoTIFF time_periods <- c("1990–1992", "1993–1995", "1996–1998", "1999–2001", "2002–2004", "2005–2007", "2008–2010", "2011–2013", "2014–2016", "2017–2019") # Load GeoTIFF data (multi-band) wealth_stack <- stack("wealth_map.tif") # Clean up out-of-range values wealth_stack[wealth_stack <= 0 | wealth_stack > 1] <- NA # Scale by 100 # wealth_stack <- 100*wealth_stack all_vals <- values(wealth_stack) all_vals <- all_vals[!is.na(all_vals)] q_breaks_legend <- quantile(all_vals, probs = seq(0, 1, 0.2), na.rm = TRUE) q_breaks <- quantile(all_vals, probs = seq(0, 1, 0.1), na.rm = TRUE) # Load improvement data (change in IWI by state/province) improvement_data <- read_csv("poverty_improvement_by_state.csv") # Pre-calculate the mean IWI for each band (for the "Trends Over Time" chart). band_means <- sapply(seq_len(nlayers(wealth_stack)), function(i) { vals <- values(wealth_stack[[i]]) vals <- vals[!is.na(vals)] mean(vals) }) # ------------------------------ # 3. UI # ------------------------------ ui <- dashboardPage( title = "Wealth Map of Africa - AI Development Lab", # Add this line # -- Header dashboardHeader( title = span( style = "font-weight: 600; font-size: 18px;", a( href = "http://aidevlab.org", "aidevlab.org", target = "_blank", style = "font-family: 'OCR A Std', monospace; color: white; text-decoration: underline;" ) ), titleWidth = 250 ), # -- Sidebar dashboardSidebar( width = 250, tags$style(HTML(" @media (max-width: 768px) { .sidebar-toggle { padding: 15px !important; } .sidebar-toggle .icon-bar { width: 25px !important; height: 3px !important; } } ")), sidebarMenu( id = "tabs", menuItem("Wealth Map", tabName = "mapTab", icon = icon("map"), selected = TRUE), menuItem("Improvement Data", tabName = "improvementTab", icon = icon("table")), menuItem("Trends Over Time", tabName = "trendTab", icon = icon("chart-line")) ), # Show inputs only for the map tab conditionalPanel( condition = "input.tabs == 'mapTab'", br(), # Larger, more touch-friendly time period slider div( style = "padding: 15px 15px 15px 15px !important;", # top right bottom left sliderInput( inputId = "time_index", label = tags$span(style = "font-size: 16px;", "Select Time Period:"), min = 1, max = length(time_periods), value = 1, step = 1, animate = animationOptions(interval = 3300, loop = TRUE), width = "100%" ) ), # Show the currently selected year range clearly div( style = "padding: 0 15px; margin-bottom: 20px;", strong(style = "font-size: 16px;", "Selected Period:"), textOutput("current_year_range", inline = TRUE) ), div( style = "padding: 0 15px;", selectInput( "color_palette", tags$span(style = "font-size: 16px;", "Color Palette:"), choices = c("Viridis" = "viridis", "Plasma" = "plasma", "Magma" = "magma", "Inferno"= "inferno", "Spectral (Brewer)" = "Spectral"), selected = "plasma", width = "100%" ) ), div( style = "padding: 0 15px; margin-bottom: 20px;", sliderInput( "opacity", tags$span(style = "font-size: 16px;", "Map Opacity:"), min = 0.2, max = 1, value = 0.8, step = 0.1, width = "100%" ) ) ), # Share button with improved mobile styling tags$div( style = "text-align: center; margin: 20px 0;", HTML(' '), # Insert the JS as well 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.left = '50%'; notification.style.transform = 'translateX(-50%)'; notification.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; notification.style.color = '#fff'; notification.style.padding = '10px 16px'; notification.style.borderRadius = '6px'; notification.style.zIndex = '9999'; notification.style.fontSize = '16px'; 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 (most mobile browsers) 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); } } }); })(); ") ) ) ), # -- Body dashboardBody( tags$head( # Viewport meta tag for proper mobile scaling tags$meta(name = "viewport", content = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"), tags$link(rel = "stylesheet", href = "https://fonts.cdnfonts.com/css/ocr-a-std"), # Additional mobile-friendly styles tags$style(HTML(" body { font-family: 'OCR A Std', monospace !important; } .slider-animate-button { background-color: #ffffff !important; color: #000000 !important; border: 2px solid #000000 !important; border-radius: 6px !important; padding: 8px 15px !important; font-size: 18px !important; margin-top: 10px !important; margin-left: 5px !important; opacity: 1 !important; top: 5px !important; position: relative !important; /* enable top/left offsets */ } .slider-animate-container { margin-top: 10px !important; /* Adjust this value as needed */ margin-bottom: 20px !important; } /* Mobile-friendly boxes and layouts */ @media (max-width: 768px) { .box { margin-bottom: 20px !important; border-radius: 8px !important; } .box-header { padding: 15px !important; } .box-title { font-size: 18px !important; } .box-body { padding: 15px !important; } .nav-tabs-custom .nav-tabs li a { padding: 15px !important; font-size: 16px !important; } /* Increase button sizes for touch */ .btn { padding: 12px 18px !important; font-size: 16px !important; } /* Larger inputs and form controls */ .form-control { height: 45px !important; font-size: 16px !important; } /* Improve DataTable mobile view */ .dataTables_wrapper .dataTables_length, .dataTables_wrapper .dataTables_filter, .dataTables_wrapper .dataTables_info, .dataTables_wrapper .dataTables_paginate { text-align: center !important; float: none !important; margin-bottom: 10px !important; } /* Make sure text doesn't overflow on small screens */ p, h1, h2, h3, h4, h5, h6 { word-wrap: break-word !important; } } /* Ensure value boxes stack nicely */ .small-box { border-radius: 8px !important; margin-bottom: 20px !important; } .small-box .icon { font-size: 70px !important; } @media (max-width: 768px) { .small-box h3 { font-size: 24px !important; } .small-box p { font-size: 16px !important; } .small-box .icon { display: none !important; } } /* Make leaflet controls more touch friendly */ .leaflet-touch .leaflet-control-layers, .leaflet-touch .leaflet-bar { border: 2px solid rgba(0,0,0,0.2) !important; } .leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { font-size: 18px !important; width: 34px !important; height: 34px !important; line-height: 34px !important; } /* Ensure plots are responsive */ .shiny-plot-output { width: 100% !important; max-width: 100% !important; } ")) ), tabItems( # ---------- MAP TAB ---------- tabItem( tabName = "mapTab", fluidRow( column( width = 12, # Value Boxes - will stack on mobile div( class = "row", div(class = "col-sm-4 col-xs-12", valueBoxOutput("highest_iwi_vb", width = NULL)), div(class = "col-sm-4 col-xs-12", valueBoxOutput("lowest_iwi_vb", width = NULL)), div(class = "col-sm-4 col-xs-12", valueBoxOutput("avg_iwi_vb", width = NULL)) ) ) ), fluidRow( # Map - full width on mobile column( width = 12, div( class = "row", div( class = "col-md-8 col-sm-12", box( title = span("Wealth Map of Africa", style = "font-family: 'OCR A Std', monospace; font-size: 18px;"), width = NULL, solidHeader = TRUE, status = "primary", leafletOutput("map", height = "450px"), p(style = "padding-top: 10px; font-size: 14px;", "Tap anywhere on the map to view the time-series of IWI for that location.") ) ), # Histogram - will position below map on mobile div( class = "col-md-4 col-sm-12", box( title = span("IWI Distribution", style = "font-family: 'OCR A Std', monospace; font-size: 16px;"), width = NULL, solidHeader = TRUE, status = "info", plotOutput("iwi_histogram", height = "200px"), p(style = "font-size: 14px;", "Distribution of International Wealth Index values for the selected time period."), strong(style = "font-size: 14px;", "Note:"), span(style = "font-size: 14px;", " Areas without human settlements are excluded."), div( style = "margin-top: 10px;", p(HTML("[Paper PDF]")) ) ) ) ) ) ), # Time series at clicked location fluidRow( column( width = 12, box( title = span("Time Series at Tapped Location", style = "font-family: 'OCR A Std', monospace; font-size: 16px;"), width = NULL, solidHeader = TRUE, status = "warning", plotOutput("clicked_ts_plot", height = "250px"), p(style = "font-size: 14px;", "Tap on the map to see the IWI time-series (1990–2019) for that location.") ) ) ), ## How It Works fluidRow( box( title = tagList(icon("cogs"), "How It Works"), status = "primary", solidHeader = TRUE, collapsible = TRUE, collapsed = TRUE, width = 12, HTML("

These wealth-index predictions are AI-generated by a sequence-aware neural network trained on 30 years of Demographic and Health Surveys (DHS) ground-truth data.

") ) ) ), # ---------- IMPROVEMENT DATA TAB ---------- tabItem( tabName = "improvementTab", fluidRow( column( width = 12, box( width = NULL, title = span("Poverty Improvement by State", style = "font-family: 'OCR A Std', monospace; font-size: 18px;"), status = "primary", solidHeader = TRUE, p(style = "font-size: 14px;", "This table shows the estimated improvement in mean IWI between 1990–1992 and 2017–2019 for each province in Africa."), div( style = "margin: 15px 0;", downloadButton("download_data", "Download CSV", style = "width: 100%; padding: 12px; font-size: 16px;") ), # Mobile-optimized table div( style = "overflow-x: auto;", DTOutput("improvement_table") ) ) ) ) ), # ---------- TRENDS OVER TIME TAB ---------- tabItem( tabName = "trendTab", fluidRow( column( width = 12, box( width = NULL, title = span("Average Wealth Index Over Time", style = "font-family: 'OCR A Std', monospace; font-size: 18px;"), status = "success", solidHeader = TRUE, p(style = "font-size: 14px;", "Mean IWI across Africa over the ten time periods, showing how wealth has changed over time."), plotOutput("trend_plot", height = "350px") ) ) ) ) ) ) ) # ------------------------------ # 4. Server # ------------------------------ server <- function(input, output, session) { # ReactiveVal to store the time-series of the last clicked point (across all periods). clicked_point_vals <- reactiveVal(NULL) # ---------------------------------- # Reactive expression for selected raster layer # ---------------------------------- selected_raster <- reactive({ req(input$time_index) wealth_stack[[input$time_index]] }) # ---------------------------------- # Custom color palette function # (reactive to user-selected palette) # ---------------------------------- color_pal <- reactive({ # Switch the user selection to a palette name palette_choice <- switch( input$color_palette, "viridis" = "viridis", "plasma" = "plasma", "magma" = "magma", "inferno" = "inferno", "Spectral" = "Spectral" ) # Create a single palette across *all* data (all_vals) using quantiles: colorBin( palette = palette_choice, domain = all_vals, bins = q_breaks, na.color = "transparent" ) }) color_pal_legend <- reactive({ # Switch the user selection to a palette name palette_choice <- switch( input$color_palette, "viridis" = "viridis", "plasma" = "plasma", "magma" = "magma", "inferno" = "inferno", "Spectral" = "Spectral" ) # Create a single palette across *all* data (all_vals) using quantiles: colorBin( palette = palette_choice, domain = all_vals, bins = q_breaks_legend, na.color = "transparent" ) }) # ---------------------------------- # Display the currently selected time period (year range) # ---------------------------------- output$current_year_range <- renderText({ time_periods[input$time_index] }) # ---------------------------------- # 1. MAP OUTPUT # ---------------------------------- output$map <- renderLeaflet({ # We'll create 5 legend steps: 1, 0.75, 0.5, 0.25, 0 legend_values <- seq(1, 0, length.out = 5) leaflet() %>% addProviderTiles(providers$OpenStreetMap) %>% setView(lng = 20, lat = 0, zoom = 3) %>% # Center on Africa addLegend( position = "bottomright", pal = color_pal_legend(), values = all_vals, # the entire distribution for the legend title = "IWI", opacity = 1 ) }) # Redraw the raster when inputs change observeEvent(list(input$time_index, input$color_palette, input$opacity), { leafletProxy("map") %>% clearImages() %>% addRasterImage( selected_raster(), colors = color_pal(), opacity = input$opacity, project = TRUE ) }) # ---------------------------------- # Handle clicks on the map to show full time-series at that location # ---------------------------------- observeEvent(input$map_click, { click <- input$map_click if (!is.null(click)) { lat <- click$lat lng <- click$lng # Convert clicked point to SpatialPoints coords <- data.frame(lng = lng, lat = lat) sp_pt <- SpatialPoints(coords, proj4string = CRS("+proj=longlat +datum=WGS84 +no_defs")) # Extract values across ALL bands at the clicked location extracted_vals <- raster::extract(wealth_stack, sp_pt) # extracted_vals is a 1x10 matrix if the point is valid if (!is.null(extracted_vals)) { # Convert to numeric vector clicked_point_vals(as.numeric(extracted_vals)) } else { # If the point is outside the raster or invalid clicked_point_vals(NULL) } } }) # Plot the time-series for the clicked location output$clicked_ts_plot <- renderPlot({ vals <- clicked_point_vals() if (is.null(vals)) { # No location clicked yet or invalid click plot.new() title("Click on the map to see the IWI time-series here.") return() } # If user clicked in a region with all NAs, do not plot if (all(is.na(vals))) { plot.new() title("No data at this location. Try another spot.") return() } df <- data.frame(Period = factor(time_periods, levels = time_periods), IWI = vals) ggplot(df, aes(x = Period, y = IWI, group = 1)) + geom_line(color = "darkorange", size = 1) + geom_point(color = "darkorange", size = 2) + labs(title = "Time Series of IWI at Clicked Location", x = "Time Period", y = "IWI (0 to 1)") + ylim(0, 1) + theme_minimal(base_size = 14) + theme(axis.text.x = element_text(angle = 45, hjust = 1)) }) # ---------------------------------- # 2. HISTOGRAM OUTPUT (for selected time period) # ---------------------------------- output$iwi_histogram <- renderPlot({ # Extract raster values for histogram r_vals <- values(selected_raster()) r_vals <- r_vals[!is.na(r_vals)] ggplot(data.frame(iwi = r_vals), aes(x = iwi)) + geom_histogram(binwidth = 0.02, fill = "#2c7bb6", color = "white", alpha = 0.7) + labs(x = "IWI (0 to 1)", y = "Frequency") + theme_minimal(base_size = 14) }) # ---------------------------------- # 3. VALUE BOXES FOR KEY STATS # ---------------------------------- # Compute stats for current raster raster_stats <- reactive({ r_vals <- values(selected_raster()) r_vals <- r_vals[!is.na(r_vals)] list( highest = max(r_vals, na.rm = TRUE), lowest = min(r_vals, na.rm = TRUE), average = mean(r_vals, na.rm = TRUE) ) }) # Highest IWI output$highest_iwi_vb <- renderValueBox({ valueBox( value = round(raster_stats()$highest, 3), subtitle = "Highest IWI", icon = icon("arrow-up"), color = "green" ) }) # Lowest IWI output$lowest_iwi_vb <- renderValueBox({ valueBox( value = round(raster_stats()$lowest, 3), subtitle = "Lowest IWI", icon = icon("arrow-down"), color = "red" ) }) # Average IWI output$avg_iwi_vb <- renderValueBox({ valueBox( value = round(raster_stats()$average, 3), subtitle = "Average IWI", icon = icon("balance-scale"), color = "blue" ) }) # ---------------------------------- # 4. IMPROVEMENT DATA TABLE # ---------------------------------- output$improvement_table <- renderDT({ datatable( improvement_data, filter = "top", options = list( scrollX = TRUE, pageLength = 20, autoWidth = TRUE ) ) }) # Download CSV output$download_data <- downloadHandler( filename = function() { paste0("poverty_improvement_", Sys.Date(), ".csv") }, content = function(file) { write.csv(improvement_data, file, row.names = FALSE) } ) # ---------------------------------- # 5. TRENDS OVER TIME (line chart of mean IWI across all Africa) # ---------------------------------- output$trend_plot <- renderPlot({ df <- data.frame( Period = factor(time_periods, levels = time_periods), MeanIWI = band_means ) ggplot(df, aes(x = Period, y = MeanIWI, group = 1)) + geom_line(color = "#2c7bb6", size = 1.1) + geom_point(color = "#2c7bb6", size = 2) + labs( title = "Average IWI Over Time (Africa)", x = "Time Period", y = "Mean IWI" ) + ylim(0.1, 0.3) + theme_minimal(base_size = 14) + theme(axis.text.x = element_text(angle = 45, hjust = 1)) }) } # ------------------------------ # 6. Run the App # ------------------------------ shinyApp(ui = ui, server = server) }