cjerzak's picture
Update app.R
9871045 verified
# 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)
}