Spaces:
Sleeping
Sleeping
# 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 | |
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( | |
# -- 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.") | |
) | |
) | |
) | |
), | |
# ---------- 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) | |
} |