Spaces:
Running
Running
| # 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(' | |
| <button id="share-button" | |
| style=" | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| padding: 12px 20px; | |
| font-size: 18px; | |
| font-weight: bold; | |
| color: #000; | |
| background-color: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| box-shadow: 0 2px 0 #000; | |
| width: 80%; | |
| "> | |
| <svg width="20" height="20" 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> | |
| '), | |
| # 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("<a href='https://doi.org/10.24963/ijcai.2023/684' target='_blank' style='font-size: 14px;'>[Paper PDF]</a>")) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| # 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(" | |
| <p>These wealth-index predictions are AI-generated by a | |
| sequence-aware neural network trained on 30 years of <em>Demographic and | |
| Health Surveys (DHS)</em> ground-truth data.</p> | |
| <ul> | |
| <li>π 57,100+ geo-referenced survey points from DHS</li> | |
| <li>βοΈ Multi-spectral satellite bands & raster-to-vector feature extraction</li> | |
| <li>π― Calibrated & validated with held-out DHS clusters (1990β2019)</li> | |
| </ul> | |
| ") | |
| ) | |
| ) | |
| ), | |
| # ---------- 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) | |
| } |