ssod / server.R
alexdum's picture
fix: change data table sort order from ascending to descending
9b16e17
Raw
History Blame Contribute Delete
60.5 kB
server <- function(input, output, session) {
parse_query_date <- function(value) {
if (is.null(value) || !nzchar(value)) {
return(NULL)
}
parsed <- suppressWarnings(as.Date(value))
if (is.na(parsed)) NULL else parsed
}
# --- State Variables ---
pending_country <- reactiveVal(NULL)
pending_station <- reactiveVal(NULL)
current_station_id <- reactiveVal(NULL)
active_export_id <- reactiveVal(NULL)
# Stages: 0=Idle, 3=Init, 4=Head, 5=Download(Chunked), 50=Download(Blocking), 6=Parse
fetch_stage <- reactiveVal(0)
fetch_retry_count <- reactiveVal(0) # Track download retries
current_fetch_token <- reactiveVal(NULL) # Track unique fetch session IDs
full_station_data <- reactiveVal(NULL)
station_info <- reactiveVal(NULL)
loading_station <- reactiveVal(FALSE)
fetch_message <- reactiveVal("Fetching high-resolution hourly data...")
fetch_tmp_path <- reactiveVal(NULL)
current_station_label <- reactiveVal("")
previous_station_choice <- reactiveVal(NULL)
previous_station_choices_list <- reactiveVal(NULL)
# Track previous date values to detect which date changed (used by
# both the midnight-rollover observer and the date-range enforcement)
prev_start_date <- reactiveVal(default_start_date)
prev_end_date <- reactiveVal(default_end_date)
# Download Progress Tracking
fetch_total_size <- reactiveVal(0)
fetch_current_pos <- reactiveVal(0)
# Map helper reactives
map_initialized <- reactiveVal(FALSE)
stations_loaded <- reactiveVal(FALSE)
current_raster_layers <- reactiveVal(character(0))
style_change_trigger <- reactiveVal(0)
stations_before_id <- reactiveVal(NULL)
basemap_debounced <- shiny::debounce(reactive(input$basemap), 200)
filters_initialized <- reactiveVal(FALSE)
deep_link_applied <- reactiveVal(FALSE)
# --- Midnight Rollover: keep end date current across days ---
# The default_end_date in global.R is frozen at container startup.
# This handles TWO scenarios:
# A) New session opened days after container start → fix on session init
# B) Long-lived session staying open past midnight → fix via hourly poll
# Poll every hour for scenario B
day_tick <- reactiveTimer(3600000)
observe({
day_tick() # re-fire every hour
# Also depend on filters_initialized so we fire once on session start
req(filters_initialized())
today <- Sys.Date()
current_start <- tryCatch(as.Date(input$date_range[1]), error = function(e) today - 1096)
current_end <- tryCatch(as.Date(input$date_range[2]), error = function(e) today)
if (is.null(current_end) || is.na(current_end)) {
return()
}
# Only auto-advance if the end date is in the past (stale)
if (current_end >= today) {
return()
}
# Only auto-advance if end date is very recent (within 2 days of today).
# This handles genuine midnight rollover (session stayed open past midnight)
# without resetting intentionally historical date ranges chosen by the user.
days_behind <- as.numeric(difftime(today, current_end, units = "days"))
if (days_behind > 2) {
return()
}
# Maintain the same window width (start -> end distance)
window_days <- as.numeric(difftime(current_end, current_start, units = "days"))
new_end <- today
new_start <- new_end - window_days
message(
"[midnight-rollover] Updating date range: ",
current_end, " -> ", new_end,
" (start: ", current_start, " -> ", new_start, ")"
)
updateDateRangeInput(session, "date_range",
start = new_start, end = new_end,
max = today
)
})
# Default map view (global, matching SSODM)
initial_lat <- 10
initial_lng <- 5
initial_zoom <- 2
# --- Initialization & Filters ---
# Show loading spinner on startup until stations are drawn
session$sendCustomMessage(
"freezeUI",
list(
text = "Loading stations...",
station = ""
)
)
# Initialize filters from the query string once on startup.
observe({
req(
!filters_initialized(),
!is.null(stations),
!is.null(session$clientData$url_search)
)
query <- shiny::parseQueryString(session$clientData$url_search %||% "")
country_choices <- sort(unique(na.omit(stations$country_name)))
selected_country <- character(0)
if (!is.null(query$country) && nzchar(query$country)) {
decoded_country <- URLdecode(query$country)
c_match <- country_choices[tolower(country_choices) == tolower(decoded_country)]
if (length(c_match) > 0) {
selected_country <- c_match[1]
pending_country(selected_country)
}
}
start_date <- parse_query_date(query$start)
end_date <- parse_query_date(query$end)
# If a station is specified in deep link, dynamically choose dates
if (!is.null(query$station) && nzchar(query$station)) {
# Handle potential URL encoding issues by decoding and standardizing case
decoded_station <- URLdecode(query$station)
meta <- stations %>%
dplyr::filter(tolower(as.character(.data$ssod_id)) == tolower(decoded_station))
if (nrow(meta) == 0) {
meta <- stations %>%
dplyr::filter(tolower(.data$name) == tolower(decoded_station))
}
if (!is.null(query$country) && nzchar(query$country)) {
decoded_country <- URLdecode(query$country)
meta <- meta %>%
dplyr::filter(tolower(.data$country_name) == tolower(decoded_country))
}
if (nrow(meta) > 0) {
# Station found. Auto-adjust missing dates to the station's most recent ~1 year
meta <- meta %>% head(1)
pending_station(as.character(meta$ssod_id[1]))
if (is.null(end_date)) {
end_date <- as.Date(paste0(meta$end_year, "-12-31"))
# Prevent going into the future
if (!is.na(end_date) && end_date > Sys.Date()) {
end_date <- Sys.Date()
}
}
if (is.null(start_date) && !is.na(end_date)) {
start_date <- max(
as.Date(paste0(meta$start_year, "-01-01"), na.rm = TRUE),
end_date - 1096
)
}
}
}
# Fallbacks if still missing — use live Sys.Date() instead of
# default_start_date/default_end_date (which are frozen at container startup)
today <- Sys.Date()
if (is.null(end_date)) {
end_date <- today
}
if (is.null(start_date)) {
start_date <- end_date - lubridate::days(1096)
}
updateSelectInput(
session,
"country",
choices = country_choices,
selected = selected_country
)
updateDateRangeInput(
session,
"date_range",
start = start_date,
end = end_date,
max = today
)
view <- query$view
if (!is.null(view)) {
later::later(function() {
if (!session$isClosed()) {
shiny::withReactiveDomain(session, {
if (identical(view, "station-info")) {
updateNavbarPage(session, "main_nav", selected = "Stations Info")
} else if (identical(view, "dashboard-plots") || identical(view, "dashboard-data")) {
updateNavbarPage(session, "main_nav", selected = "Dashboard")
}
})
}
}, delay = 0.5)
}
filters_initialized(TRUE)
})
# Reactive station filtering
filtered_stations <- reactive({
if (is.null(stations)) {
return(NULL)
}
data <- stations
if (!is.null(input$country) && length(input$country) > 0) {
data <- data %>% filter(.data$country_name %in% input$country)
}
sel_start_yr <- lubridate::year(input$date_range[1])
sel_end_yr <- lubridate::year(input$date_range[2])
data <- data %>%
filter(
.data$start_year <= sel_end_yr,
(.data$end_year >= sel_start_yr | .data$end_year >= max_year_data)
)
data %>% filter(!is.na(.data$latitude), !is.na(.data$longitude))
})
# SF version for maplibre
filtered_stations_sf <- reactive({
df <- filtered_stations()
req(df)
sf::st_as_sf(
df,
coords = c("longitude", "latitude"),
crs = 4326,
remove = FALSE
)
})
# Reactive for country-filtered data (zooming logic)
country_stations <- reactive({
if (is.null(stations)) {
return(NULL)
}
sel_start_yr <- lubridate::year(input$date_range[1])
sel_end_yr <- lubridate::year(input$date_range[2])
data <- stations %>%
filter(
.data$start_year <= sel_end_yr,
(.data$end_year >= sel_start_yr | .data$end_year >= max_year_data)
)
if (!is.null(input$country) && length(input$country) > 0) {
data <- data %>% filter(.data$country_name %in% input$country)
}
data
})
# Observer to auto-zoom
observeEvent(
input$country,
{
if (!is.null(pending_country())) {
if (isTRUE(input$country == pending_country())) {
pending_country(NULL) # Match found, proceed and clear
} else {
return() # Block update until country catches up
}
}
df <- country_stations()
if (is.null(df) || nrow(df) == 0) {
maplibre_proxy("map") %>%
fly_to(center = c(initial_lng, initial_lat), zoom = initial_zoom)
return()
}
if (!is.null(input$country) && length(input$country) > 0) {
rng_lat <- range(df$latitude, na.rm = TRUE)
rng_lng <- range(df$longitude, na.rm = TRUE)
maplibre_proxy("map") %>%
fit_bounds(
c(rng_lng[1], rng_lat[1], rng_lng[2], rng_lat[2]),
animate = TRUE
)
} else {
maplibre_proxy("map") %>%
fly_to(center = c(initial_lng, initial_lat), zoom = initial_zoom)
}
},
ignoreNULL = FALSE
)
output$station_count_filtered <- renderText({
df <- visible_stations()
if (is.null(df)) {
return("Loading data...")
}
paste("Stations showing:", scales::comma(nrow(df)))
})
output$map <- renderMaplibre({
maplibre(
style = ofm_positron_style,
center = c(initial_lng, initial_lat),
zoom = initial_zoom
) %>%
add_navigation_control(
show_compass = FALSE,
visualize_pitch = FALSE,
position = "top-left"
)
})
# Handle initial map load - use map_zoom as readiness indicator
# Delay slightly to ensure the MapLibre style is fully loaded before adding layers
observe({
req(!map_initialized())
req(input$map_zoom)
later::later(
function() {
if (session$isClosed()) {
return()
}
map_initialized(TRUE)
},
delay = 0.5
)
})
observe({
is_loading <- loading_station()
inputs_to_toggle <- c(
"country",
"date_range",
"zoom_home",
"main_nav",
"download_hourly",
"download_daily"
)
if (is_loading) {
for (inp in inputs_to_toggle) {
shinyjs::disable(inp)
}
} else {
for (inp in inputs_to_toggle) {
shinyjs::enable(inp)
}
}
})
# --- Station Selector Logic ---
# Update choices based on filtered stations
observe({
df <- filtered_stations()
req(df)
# Create choices: "Station Name (ID)" = "ID"
ids <- as.character(df$ssod_id)
names <- paste0(as.character(df$name), " (", ids, ")")
if (length(ids) > 0) {
new_choices <- setNames(ids, names)
} else {
new_choices <- character(0)
}
# Only update if choices have actually changed (compare IDs)
# This prevents redrawing the input unnecessarily and resetting state
prev_choices <- previous_station_choices_list()
# Sort for comparison content-wise
new_ids_sorted <- sort(unname(new_choices))
prev_ids_sorted <- if (!is.null(prev_choices)) {
sort(unname(prev_choices))
} else {
NULL
}
if (
is.null(prev_ids_sorted) || !identical(new_ids_sorted, prev_ids_sorted)
) {
# Preserve selection if still in filtered list
current_sel <- input$station_selector
# If current_sel is NULL or empty, use character(0) to ensure no selection is made
sel_arg <- if (is.null(current_sel) || current_sel == "") {
character(0)
} else {
current_sel
}
if (!is.null(pending_station())) {
if (pending_station() %in% ids) {
sel_arg <- pending_station()
}
}
updateSelectizeInput(
session,
"station_selector",
choices = new_choices,
selected = sel_arg,
server = FALSE
)
previous_station_choices_list(new_choices)
}
})
# Handle selection from dropdown
observeEvent(input$station_selector, {
req(input$station_selector)
id_val <- input$station_selector
if (!is.null(pending_station())) {
if (isTRUE(id_val == pending_station())) {
pending_station(NULL) # Applied successfully
} else {
id_val <- pending_station() # Prioritize deep-linked value while client updates
}
}
# Avoid potential loop if the updates come from map click
prev <- previous_station_choice()
if (!is.null(prev) && prev == id_val) {
return()
}
# Check if we should trigger selection
curr <- current_station_id()
if (!is.null(curr) && curr == id_val) {
return()
}
# Verify station exists in current list
meta <- stations %>% dplyr::filter(.data$ssod_id == id_val)
if (nrow(meta) > 0) {
select_station(id_val)
maplibre_proxy("map") %>%
fly_to(center = c(meta$longitude[1], meta$latitude[1]), zoom = 9)
previous_station_choice(id_val)
}
})
# Zoom to extent of all filtered stations
observeEvent(input$zoom_home, {
df <- filtered_stations()
req(df)
if (nrow(df) > 0) {
# Calculate bounds
lons <- range(df$longitude, na.rm = TRUE)
lats <- range(df$latitude, na.rm = TRUE)
# If only one station, zoom to it with a small buffer
if (nrow(df) == 1) {
maplibre_proxy("map") %>%
fly_to(center = c(df$longitude[1], df$latitude[1]), zoom = 12)
} else {
maplibre_proxy("map") %>%
fit_bounds(c(lons[1], lats[1], lons[2], lats[2]), animate = TRUE)
}
} else {
# Fallback to default
maplibre_proxy("map") %>%
fly_to(center = c(initial_lng, initial_lat), zoom = initial_zoom)
}
})
# Emit state to parent for deep linking
observeEvent(c(input$country, input$station_selector), {
c_val <- input$country
s_val <- input$station_selector
country_out <- if (!is.null(c_val) && length(c_val) > 0 && nzchar(c_val[1])) c_val[1] else NULL
station_out <- if (!is.null(s_val) && nzchar(s_val)) s_val else NULL
st_name_out <- NULL
if (!is.null(station_out) && !is.null(stations)) {
meta <- stations %>% dplyr::filter(.data$ssod_id == station_out) %>% head(1)
if (nrow(meta) > 0) {
st_name_out <- as.character(meta$name[1])
}
}
if (!is.null(pending_country())) {
country_out <- pending_country()
}
if (!is.null(pending_station())) {
station_out <- pending_station()
if (!is.null(stations)) {
meta <- stations %>% dplyr::filter(.data$ssod_id == station_out) %>% head(1)
if (nrow(meta) > 0) st_name_out <- as.character(meta$name[1])
}
}
session$sendCustomMessage("ssod-state-update", list(
type = "ssod-state-update",
country = country_out,
station = station_out,
stationName = st_name_out
))
}, ignoreInit = FALSE, ignoreNULL = FALSE)
# --- Helper: Broadcast current state to parent page ---
broadcast_state <- function(view_override = NULL) {
# Get active station
sid <- isolate(current_station_id())
st_meta <- NULL
if (!is.null(sid)) {
st_meta <- stations %>%
dplyr::filter(.data$ssod_id == sid) %>%
head(1)
}
station_id <- if (!is.null(st_meta) && nrow(st_meta) > 0) {
as.character(st_meta$ssod_id)
} else {
NULL
}
station_name <- if (!is.null(st_meta) && nrow(st_meta) > 0) {
as.character(st_meta$name)
} else {
NULL
}
country <- if (!is.null(st_meta) && nrow(st_meta) > 0) {
as.character(st_meta$country_name)
} else {
NULL
}
# Determine current view
main_tab <- isolate(input$main_nav)
view <- if (!is.null(view_override)) {
view_override
} else if (!is.null(main_tab)) {
if (main_tab == "Map View") {
"map"
} else if (main_tab == "Stations Info") {
"station-info"
} else if (main_tab == "Dashboard") {
subtab <- isolate(input$dashboard_subtabs)
if (!is.null(subtab) && subtab == "Data") {
"dashboard-data"
} else {
"dashboard-plots"
}
} else {
"map"
}
} else {
"map"
}
start_date <- if (!is.null(isolate(input$date_range))) {
as.character(isolate(input$date_range)[1])
} else {
NULL
}
end_date <- if (!is.null(isolate(input$date_range))) {
as.character(isolate(input$date_range)[2])
} else {
NULL
}
session$sendCustomMessage(
"updateParentURL",
list(
station = station_id,
stationName = station_name,
country = country,
view = view,
start = start_date,
end = end_date
)
)
}
# Fix Map Rendering on Tab Switch & Broadcast State
observeEvent(input$main_nav, {
if (input$main_nav == "Map View") {
# Slight delay to ensure the tab is visible
shinyjs::runjs(
"
setTimeout(function() {
var map = HTMLWidgets.find('#map');
if (map) {
// mapgl uses resize() which is roughly equivalent to invalidateSize()
map.getMap().resize();
}
}, 200);
"
)
}
# Broadcast state on tab change
broadcast_state()
})
# Broadcast state on dashboard subtab change
observeEvent(
input$dashboard_subtabs,
{
broadcast_state()
},
ignoreInit = TRUE
)
# --- Basemap Switching Logic ---
label_layer_ids <- c(
# OpenFreeMap Positron & Bright common labels
"waterway_line_label",
"water_name_point_label",
"water_name_line_label",
"highway-name-path",
"highway-name-minor",
"highway-name-major",
"highway-shield-non-us",
"highway-shield-us-interstate",
"road_shield_us",
"airport",
"label_other",
"label_village",
"label_town",
"label_state",
"label_city",
"label_city_capital",
"label_country_3",
"label_country_2",
"label_country_1",
# Bright specific labels (POIs & Directions)
"road_oneway",
"road_oneway_opposite",
"poi_r20",
"poi_r7",
"poi_r1",
"poi_transit",
# Dash variants
"waterway-line-label",
"water-name-point-label",
"water-name-line-label",
"highway-shield-non-us",
"highway-shield-us-interstate",
"road-shield-us",
"label-other",
"label-village",
"label-town",
"label-state",
"label-city",
"label-city-capital",
"label-country-3",
"label-country-2",
"label-country-1",
# Legacy/Carto/OSM
"place_villages",
"place_town",
"place_country_2",
"place_country_1",
"place_state",
"place_continent",
"place_city_r6",
"place_city_r5",
"place_city_dot_r7",
"place_city_dot_r4",
"place_city_dot_r2",
"place_city_dot_z7",
"place_capital_dot_z7",
"place_capital",
"roadname_minor",
"roadname_sec",
"roadname_pri",
"roadname_major",
"motorway_name",
"watername_ocean",
"watername_sea",
"watername_lake",
"watername_lake_line",
"poi_stadium",
"poi_park",
"poi_zoo",
"airport_label",
"country-label",
"state-label",
"settlement-major-label",
"settlement-minor-label",
"settlement-subdivision-label",
"road-label",
"waterway-label",
"natural-point-label",
"poi-label",
"airport-label"
)
non_label_layer_ids <- c(
"background",
"park",
"water",
"landcover_ice_shelf",
"landcover_glacier",
"landuse_residential",
"landcover_wood",
"waterway",
"building",
"tunnel_motorway_casing",
"tunnel_motorway_inner",
"aeroway-taxiway",
"aeroway-runway-casing",
"aeroway-area",
"aeroway-runway",
"road_area_pier",
"road_pier",
"highway_path",
"highway_minor",
"highway_major_casing",
"highway_major_inner",
"highway_major_subtle",
"highway_motorway_casing",
"highway_motorway_inner",
"highway_motorway_subtle",
"railway_transit",
"railway_transit_dashline",
"railway_service",
"railway_service_dashline",
"railway",
"railway_dashline",
"highway_motorway_bridge_casing",
"highway_motorway_bridge_inner",
"boundary_3",
"boundary_2",
"boundary_disputed"
)
apply_label_visibility <- function(proxy, show_labels) {
visibility <- if (isTRUE(show_labels)) "visible" else "none"
for (layer_id in label_layer_ids) {
tryCatch(
{
proxy %>% set_layout_property(layer_id, "visibility", visibility)
},
error = function(e) {}
)
}
}
observeEvent(basemap_debounced(), {
basemap <- basemap_debounced()
proxy <- maplibre_proxy("map")
if (basemap %in% c("ofm_positron", "ofm_bright")) {
style_url <- if (basemap == "ofm_positron") {
ofm_positron_style
} else {
ofm_bright_style
}
proxy %>% set_style(style_url, preserve_layers = FALSE)
stations_before_id("waterway_line_label")
current_session <- shiny::getDefaultReactiveDomain()
selected_basemap <- basemap
later::later(
function() {
if (current_session$isClosed()) {
return()
}
shiny::withReactiveDomain(current_session, {
current_basemap <- isolate(input$basemap)
if (current_basemap != selected_basemap) {
return()
}
apply_label_visibility(
maplibre_proxy("map"),
isolate(input$show_labels)
)
style_change_trigger(isolate(style_change_trigger()) + 1)
})
},
delay = 0.35
)
} else if (basemap == "sentinel") {
proxy %>% set_style(ofm_positron_style, preserve_layers = FALSE)
current_session <- shiny::getDefaultReactiveDomain()
selected_basemap <- basemap
later::later(
function() {
if (current_session$isClosed()) {
return()
}
shiny::withReactiveDomain(current_session, {
current_basemap <- isolate(input$basemap)
if (current_basemap != selected_basemap) {
return()
}
unique_suffix <- as.numeric(Sys.time()) * 1000
source_id <- paste0("sentinel_source_", unique_suffix)
layer_id <- paste0("sentinel_layer_", unique_suffix)
maplibre_proxy("map") %>%
add_raster_source(
id = source_id,
tiles = c(sentinel_url),
tileSize = 256,
attribution = sentinel_attribution
) %>%
add_layer(
id = layer_id,
type = "raster",
source = source_id,
paint = list("raster-opacity" = 1),
before_id = "background"
)
for (layer_id_kill in non_label_layer_ids) {
tryCatch(
{
maplibre_proxy("map") %>%
set_layout_property(layer_id_kill, "visibility", "none")
},
error = function(e) {}
)
}
apply_label_visibility(
maplibre_proxy("map"),
isolate(input$show_labels)
)
stations_before_id("waterway_line_label")
style_change_trigger(isolate(style_change_trigger()) + 1)
})
},
delay = 0.5
)
}
}, ignoreInit = TRUE)
observeEvent(
input$show_labels,
{
apply_label_visibility(maplibre_proxy("map"), input$show_labels)
},
ignoreInit = TRUE
)
# Update markers
observe({
df <- filtered_stations_sf()
req(df, map_initialized())
style_change_trigger()
# popup_content is pre-computed in global.R (vectorized, no per-render overhead)
proxy <- maplibre_proxy("map")
before <- stations_before_id()
proxy %>%
clear_layer("stations_layer") %>%
add_circle_layer(
id = "stations_layer",
source = df,
circle_color = "navy",
circle_radius = 6,
circle_opacity = 0.7,
circle_stroke_width = 0,
before_id = before,
tooltip = "popup_content"
)
# Re-apply highlight if a station is selected
sel_id <- isolate(current_station_id())
if (!is.null(sel_id)) {
sel_meta <- stations %>% dplyr::filter(.data$ssod_id == sel_id)
if (nrow(sel_meta) > 0) {
highlight_selected_station(proxy, sel_meta[1, ])
}
}
# Dismiss the startup loading spinner once data is sent to the map
if (!isolate(stations_loaded())) {
stations_loaded(TRUE)
nav <- isolate(input$main_nav)
is_map_active <- is.null(nav) || nav == "Map"
if (is_map_active) {
session$sendCustomMessage("unfreezeWhenMapIdle", list())
} else {
session$sendCustomMessage("unfreezeUI", list())
}
}
})
# --- Date Range Enforcement & Freeze Feedback ---
observeEvent(
input$date_range,
{
req(input$date_range)
start <- input$date_range[1]
end <- input$date_range[2]
# Check validity (non-null)
if (is.na(start) || is.na(end)) {
return()
}
start_changed <- !identical(start, prev_start_date())
end_changed <- !identical(end, prev_end_date())
if (!start_changed && !end_changed) {
return()
}
# Calculate difference
diff_days <- as.numeric(difftime(end, start, units = "days"))
# Enforce 1096-day limit bidirectionally
if (diff_days > 1096) {
if (start_changed && !end_changed) {
# Start date was changed - adjust end date
new_end <- start + 1096
updateDateRangeInput(
session,
"date_range",
start = start,
end = new_end
)
showNotification(
"Date range cannot exceed 1096 days. Adjusting end date.",
type = "warning",
duration = 4
)
} else if (end_changed && !start_changed) {
# End date was changed - adjust start date
new_start <- end - 1096
updateDateRangeInput(
session,
"date_range",
start = new_start,
end = end
)
showNotification(
"Date range cannot exceed 1096 days. Adjusting start date.",
type = "warning",
duration = 4
)
} else {
# Both changed or unclear - default to adjusting end date
new_end <- start + 1096
updateDateRangeInput(
session,
"date_range",
start = start,
end = new_end
)
showNotification(
"Date range cannot exceed 1096 days. Adjusting end date.",
type = "warning",
duration = 4
)
}
# Show feedback (Blocking)
msg <- "Date range limited to 1096 days. Updating..."
session$sendCustomMessage(
"freezeUI",
list(
text = msg,
station = current_station_label()
)
)
} else {
# Valid change - update previous values
prev_start_date(start)
prev_end_date(end)
# Show loading feedback if we have a station context
# This provides visual feedback while the reactive graph updates
if (!is.null(current_station_id())) {
select_station(current_station_id())
return()
}
}
# Wait for plots to render before unfreezing
session$sendCustomMessage(
"freezeUI",
list(text = "Rendering plots...", allowCancel = TRUE)
)
session$onFlushed(
function() {
session$sendCustomMessage("waitForPlots", list())
},
once = TRUE
)
},
ignoreInit = TRUE
)
# --- Data Logic ---
full_station_data <- reactiveVal(NULL)
station_data <- reactive({
df <- full_station_data()
req(df, input$date_range)
# Validate 1096-day limit to prevent double-rendering
# If range is too large, stop here and wait for the observer to correct it
diff_days <- as.numeric(difftime(
input$date_range[2],
input$date_range[1],
units = "days"
))
req(diff_days <= 1096)
# Use sidebar date range for filtering
start_dt <- lubridate::as_datetime(input$date_range[1])
end_dt <- lubridate::as_datetime(input$date_range[2]) +
lubridate::hours(23) +
lubridate::minutes(59)
if (length(start_dt) == 0 || length(end_dt) == 0) {
return(df)
}
df %>%
dplyr::filter(.data$datetime >= start_dt, .data$datetime <= end_dt) %>%
dplyr::arrange(.data$datetime)
})
# --- Fetching Logic (The State Machine) ---
reset_fetch <- function(msg = NULL) {
if (!is.null(msg)) {
message("reset_fetch called with message: ", msg)
# Don't show generic "Cancelled by user" here since the cancel button already shows a notification
if (msg != "Cancelled by user") {
showNotification(msg, type = "error", duration = NULL)
}
}
# Invalidate token to kill pending async tasks
current_fetch_token(as.numeric(Sys.time()))
loading_station(FALSE)
fetch_stage(0)
fetch_retry_count(0)
fetch_total_size(0)
fetch_current_pos(0)
if (!is.null(msg)) {
station_info(list(name = msg, id = current_station_id()))
}
tmp <- fetch_tmp_path()
if (!is.null(tmp) && file.exists(tmp)) {
unlink(tmp)
}
fetch_tmp_path(NULL)
# Unfreeze UI when fetch is reset/cancelled
session$sendCustomMessage("unfreezeUI", list())
}
# Handle Cancel from Freeze Window
observeEvent(input$cancel_loading, {
reset_fetch("Cancelled by user")
showNotification("Loading cancelled by user.", type = "warning")
})
# Helper for station selection (shared by map and table)
select_station <- function(id) {
req(id)
# Pre-fetch meta to get name/country for the loading screen
meta <- stations %>% dplyr::filter(.data$ssod_id == id)
if (nrow(meta) > 0) {
s_name <- meta$name
s_country <- ifelse(
is.na(meta$country_name),
"Unknown",
meta$country_name
)
current_station_label(paste0(s_name, ", ", s_country))
} else {
current_station_label(paste("Station", id))
}
full_station_data(NULL)
loading_station(TRUE)
fetch_message("Fetching high-resolution hourly data...")
# Freeze UI during download and parsing
session$sendCustomMessage(
"freezeUI",
list(
text = "Downloading station data...",
station = current_station_label()
)
)
fetch_total_size(0)
fetch_current_pos(0)
# Session identification
new_token <- as.numeric(Sys.time())
current_fetch_token(new_token)
current_station_id(id)
message("Station selected: station=", id, " token=", new_token)
active_export_id(id)
if (nrow(meta) == 0) {
loading_station(FALSE)
session$sendCustomMessage("unfreezeUI", list())
return()
}
station_info(list(name = meta$name, id = meta$ssod_id))
# Start fetch
fetch_stage(3)
# Highlight selected marker using helper function
highlight_selected_station(maplibre_proxy("map"), meta)
# Broadcast state change to parent page
broadcast_state()
}
# Stage 1: Initial Click on Map
observeEvent(input$map_feature_click, {
clicked_data <- input$map_feature_click
if (is.null(clicked_data)) {
return()
}
# Check which layer was clicked
layer_id <- clicked_data$layer_id %||% clicked_data$layer
# Check if user clicked the highlight ring
if (isTRUE(layer_id == "selected_highlight")) {
if (!is.null(full_station_data())) {
updateNavbarPage(session, "main_nav", selected = "Dashboard")
}
return()
}
# Only handle clicks on the stations layer
if (!isTRUE(layer_id == "stations_layer")) {
return()
}
# Extract station ID from feature properties
id <- clicked_data$properties$ssod_id
if (is.null(id)) {
return()
}
select_station(id)
# Zoom and center on click
meta <- stations %>%
dplyr::filter(.data$ssod_id == id) %>%
head(1)
if (nrow(meta) > 0) {
maplibre_proxy("map") %>%
fly_to(center = c(meta$longitude, meta$latitude), zoom = 9)
}
# Sync dropdown
updateSelectizeInput(session, "station_selector", selected = id)
previous_station_choice(id)
})
# Stage 1b: Selection from Table - Double Click
output$table <- DT::renderDataTable({
df <- visible_stations()
if (is.null(df)) {
return(NULL)
}
df %>%
dplyr::select(
.data$ssod_id,
.data$name,
.data$country_name,
.data$start_year,
.data$end_year,
.data$elevation,
.data$state
) %>%
DT::datatable(
options = list(
pageLength = 25,
columnDefs = list(
list(
targets = c(0, 5, 6),
responsivePriority = 10000
)
),
responsive = TRUE
),
rownames = FALSE,
selection = "none",
colnames = c(
"ID",
"Station Name",
"Country",
"Start Year",
"End Year",
"Elevation (m)",
"State/Prov"
),
callback = JS(
"
table.on('dblclick', 'tr', function() {
var rowData = table.row(this).data();
if (rowData !== undefined && rowData !== null) {
var stationId = rowData[0];
Shiny.setInputValue('table_station_dblclick', stationId, {priority: 'event'});
if (window.collapseSidebarOnMobile) window.collapseSidebarOnMobile();
}
});
table.on('click', 'tr', function() {
if (window.innerWidth <= 768) {
var rowData = table.row(this).data();
if (rowData !== undefined && rowData !== null) {
var stationId = rowData[0];
Shiny.setInputValue('table_station_dblclick', stationId, {priority: 'event'});
if (window.collapseSidebarOnMobile) window.collapseSidebarOnMobile();
}
}
});
"
)
)
})
observeEvent(input$table_station_dblclick, {
id_val <- input$table_station_dblclick
req(id_val)
# Verify station exists
meta <- stations %>% dplyr::filter(.data$ssod_id == id_val)
if (nrow(meta) == 0) {
return()
}
select_station(id_val)
# Highlight on map and go to Map View
maplibre_proxy("map") %>%
fly_to(center = c(meta$longitude[1], meta$latitude[1]), zoom = 9)
# Sync dropdown
updateSelectizeInput(session, "station_selector", selected = id_val)
previous_station_choice(id_val)
})
# Render Panel Header
output$panel_station_name <- renderText({
id <- current_station_id()
if (is.null(id)) {
return("")
}
meta <- stations %>%
filter(.data$ssod_id == id) %>%
first()
if (is.null(meta)) {
return("")
}
country <- ifelse(
is.na(meta$country_name) || meta$country_name == "",
"Unknown",
meta$country_name
)
paste0(meta$name, ", ", country, " (", id, ")")
})
output$panel_station_meta <- renderText({
id <- current_station_id()
if (is.null(id)) {
return("")
}
meta <- stations %>%
filter(.data$ssod_id == id) %>%
first()
if (is.null(meta)) {
return("")
}
period <- ifelse(
is.na(meta$start_year),
"Unknown",
paste0(meta$start_year, " - ", meta$end_year)
)
paste0("Period: ", period, " | Elev: ", meta$elevation, "m")
})
# --- Async Fetch Machine ---
# State variables for year-by-year fetching
years_to_fetch <- reactiveVal(numeric(0))
fetched_data <- reactiveVal(list())
current_year_idx <- reactiveVal(1)
# Stage 3: Init Fetching
observe({
req(fetch_stage() == 3)
station_id <- current_station_id()
if (is.null(station_id)) {
reset_fetch()
return()
}
current_start <- isolate(input$date_range[1])
current_end <- isolate(input$date_range[2])
if (is.null(current_start) || is.null(current_end)) {
reset_fetch("Invalid date range.")
return()
}
start_yr <- as.integer(format(as.Date(current_start), "%Y"))
end_yr <- as.integer(format(as.Date(current_end), "%Y"))
years <- seq(start_yr, end_yr)
years_to_fetch(years)
fetched_data(list())
current_year_idx(1)
fetch_retry_count(0)
fetch_message(paste("Checking availability for", length(years), "years..."))
this_token <- current_fetch_token()
later::later(
function() {
if (session$isClosed()) {
return()
}
if (identical(isolate(current_fetch_token()), this_token)) {
fetch_stage(4)
}
},
0.1
)
fetch_stage(-1)
})
# Stage 4: Fetch Loop
observe({
req(fetch_stage() == 4)
station_id <- current_station_id()
if (is.null(station_id)) {
reset_fetch()
return()
}
idx <- current_year_idx()
years <- years_to_fetch()
if (idx > length(years)) {
# Transition to finalize stage
fetch_stage(6)
return()
}
year <- years[idx]
fetch_message(paste0("Fetching data for ", year, " (", idx, "/", length(years), ")..."))
session$sendCustomMessage("freezeUI", list(text = paste0("Fetching data for ", year, " (", idx, "/", length(years), ")..."), station = current_station_label()))
res <- fetch_ssod_year_mem(station_id, year)
if (!is.null(res$error)) {
if (res$error == "transient") {
if (fetch_retry_count() < 3) {
fetch_retry_count(fetch_retry_count() + 1)
fetch_message(paste("Network drop. Retrying year", year, "- Attempt", fetch_retry_count()))
this_token <- current_fetch_token()
later::later(function() {
if (session$isClosed()) {
return()
}
if (identical(isolate(current_fetch_token()), this_token)) {
fetch_stage(4)
}
}, 1.0)
fetch_stage(-1)
return()
} else {
reset_fetch(paste("Connection failed after retries for year", year))
return()
}
} else {
reset_fetch(paste("Fatal error fetching year", year, ":", res$message))
return()
}
}
if (res$status == 200) {
# Parse data
tmp <- tempfile(fileext = ".csv")
writeBin(res$content, tmp)
tryCatch(
{
df <- parse_ssod_data(tmp)
if (!is.null(df) && nrow(df) > 0) {
cur_data <- fetched_data()
cur_data[[as.character(year)]] <- df
fetched_data(cur_data)
}
},
error = function(e) {
# Ignore parse errors for a single year and continue
}
)
unlink(tmp)
}
# Success or 404, move to next year
fetch_retry_count(0)
current_year_idx(idx + 1)
this_token <- current_fetch_token()
later::later(function() {
if (session$isClosed()) {
return()
}
if (identical(isolate(current_fetch_token()), this_token)) {
fetch_stage(4)
}
}, 0.1)
fetch_stage(-1)
})
# Stage 6: Combine & Finalize
observe({
req(fetch_stage() == 6)
dfs <- fetched_data()
if (length(dfs) > 0) {
df_combined <- bind_rows(dfs) %>% arrange(datetime)
full_station_data(df_combined)
# Auto-adjust date range if current range has no data
current_start <- isolate(input$date_range[1])
current_end <- isolate(input$date_range[2])
if (!is.null(current_start) && !is.null(current_end)) {
actual_max_date <- as.Date(max(df_combined$datetime, na.rm = TRUE))
actual_min_date <- as.Date(min(df_combined$datetime, na.rm = TRUE))
if (as.Date(current_start) > actual_max_date || as.Date(current_end) < actual_min_date) {
new_end <- actual_max_date
new_start <- max(actual_min_date, new_end - 1096)
updateDateRangeInput(session, "date_range", start = new_start, end = new_end)
prev_start_date(new_start)
prev_end_date(new_end)
}
}
mem_size <- format(object.size(df_combined), units = "Mb")
final_msg <- paste0("Processing Complete!<br><span style='font-size: 0.9em; color: #555;'>In-Memory Data: <b>", mem_size, "</b></span>")
fetch_message(final_msg)
loading_station(FALSE)
fetch_stage(0)
this_token <- current_fetch_token()
session$sendCustomMessage("freezeUI", list(text = "Rendering plots...", station = current_station_label()))
updateNavbarPage(session, "main_nav", selected = "Dashboard")
session$sendCustomMessage("freezeUI", list(text = "Rendering plots...", allowCancel = TRUE))
session$onFlushed(function() {
if (identical(isolate(current_fetch_token()), this_token)) {
session$sendCustomMessage("waitForPlots", list())
}
}, once = TRUE)
} else {
reset_fetch("No valid records found in the selected date range.")
}
fetch_stage(-1)
})
output$station_ready <- reactive({
!is.null(full_station_data())
})
outputOptions(output, "station_ready", suspendWhenHidden = FALSE)
output$is_loading <- reactive({
loading_station()
})
outputOptions(output, "is_loading", suspendWhenHidden = FALSE)
# --- Plotting & Tables ---
output$temp_plot <- renderPlotly({
df <- station_data()
create_temperature_plot(df)
})
output$humidity_plot <- renderPlotly({
df <- station_data()
create_humidity_plot(df)
})
output$wind_overview_plot <- renderPlotly({
df <- station_data()
create_wind_overview_plot(df)
})
output$pressure_plot <- renderPlotly({
df <- station_data()
create_pressure_plot(df)
})
output$visibility_plot <- renderPlotly({
df <- station_data()
create_visibility_plot(df)
})
output$precip_plot <- renderPlotly({
df <- station_data()
create_precipitation_plot(df)
})
output$wind_rose <- renderPlotly({
df <- station_data()
create_wind_rose_plot(df)
})
# Station Info Header - Info Callout Card (like DWD)
output$station_info_header <- renderUI({
id <- current_station_id()
if (is.null(id)) {
return(NULL)
}
# Metadata
meta <- stations %>%
dplyr::filter(.data$ssod_id == id) %>%
dplyr::first()
if (is.null(meta)) {
return(NULL)
}
s_name <- meta$name
s_country <- ifelse(
is.na(meta$country_name) || meta$country_name == "",
"Unknown",
meta$country_name
)
s_elev <- meta$elevation
# Data Range
df <- station_data()
completeness_text <- ""
if (is.null(df) || nrow(df) == 0) {
dates_text <- "No data loaded"
} else {
date_range <- range(as.Date(df$datetime), na.rm = TRUE)
dates_text <- paste(date_range[1], "to", date_range[2])
completeness_text <- calculate_data_completeness(df, input$date_range)
}
# Station Available Period
meta_start <- ifelse(is.null(meta$start_year) || is.na(meta$start_year), "?", meta$start_year)
meta_end <- ifelse(is.null(meta$end_year) || is.na(meta$end_year), "?", meta$end_year)
avail_text <- paste(meta_start, "to", meta_end)
# Unified Info Card
card(
style = "margin-bottom: 20px; border-left: 5px solid #007bff;",
card_body(
padding = 15,
layout_columns(
fill = FALSE,
# Col 1: Station
div(
strong("Station"),
br(),
span(s_name, style = "font-size: 1.1rem;"),
br(),
tags$small(class = "text-muted", paste("ID:", id))
),
# Col 2: Location
div(
strong("Location"),
br(),
span(s_country),
br(),
tags$small(
class = "text-muted",
paste0(meta$latitude, "°N, ", meta$longitude, "°E")
)
),
# Col 3: Elevation & Technical
div(
strong("Technical"),
br(),
span(paste0(s_elev, " m")),
br(),
span(class = "badge bg-primary", "Hourly")
),
# Col 4: Period
div(
strong("Station Availability"),
br(),
span(avail_text),
br(),
tags$small(class = "text-muted", paste("Viewing:", dates_text)),
if (completeness_text != "") br() else NULL,
if (completeness_text != "") tags$small(class = "text-success fw-bold", completeness_text) else NULL
)
)
)
)
})
# Dynamic Details Tabs
# Dynamic Details Tabs - Grid Layout (DWD Style)
output$details_tabs <- renderUI({
df <- station_data()
req(df)
# Check data availability
t_vals <- if ("temp" %in% names(df)) suppressWarnings(as.numeric(df$temp)) else rep(NA_real_, nrow(df))
tmin_vals <- if ("temp_min" %in% names(df)) suppressWarnings(as.numeric(df$temp_min)) else rep(NA_real_, nrow(df))
tmax_vals <- if ("temp_max" %in% names(df)) suppressWarnings(as.numeric(df$temp_max)) else rep(NA_real_, nrow(df))
t_vals[t_vals < -90 | t_vals > 60] <- NA_real_
tmin_vals[tmin_vals < -90 | tmin_vals > 60] <- NA_real_
tmax_vals[tmax_vals < -90 | tmax_vals > 60] <- NA_real_
invalid_rel <- !is.na(tmin_vals) & !is.na(tmax_vals) & (tmax_vals < tmin_vals)
tmin_vals[invalid_rel] <- NA_real_
tmax_vals[invalid_rel] <- NA_real_
has_temp <- any(!is.na(t_vals))
has_tmin <- any(!is.na(tmin_vals))
has_tmax <- any(!is.na(tmax_vals))
has_humidity <- ("rh" %in% names(df) && any(!is.na(df$rh))) ||
("dew_point" %in% names(df) && any(!is.na(df$dew_point)))
has_wind <- ("wind_speed" %in% names(df) && any(!is.na(df$wind_speed))) ||
("wind_gust" %in% names(df) && any(!is.na(df$wind_gust)))
has_wind_rose <- has_wind &&
"wind_dir" %in% names(df) &&
any(!is.na(df$wind_dir))
has_pressure <- ("pressure" %in% names(df) && any(!is.na(df$pressure))) ||
("station_pressure" %in% names(df) && any(!is.na(df$station_pressure)))
has_vis <- "vis" %in% names(df) && any(!is.na(df$vis))
# Check precip columns — only the numeric ones, not raw/code/qc/source metadata
precip_numeric_names <- c(
"precip", "precip_3h", "precip_6h", "precip_9h",
"precip_12h", "precip_15h", "precip_18h",
"precip_21h", "precip_24h"
)
precip_cols <- intersect(precip_numeric_names, names(df))
has_precip <- length(precip_cols) > 0 &&
any(sapply(df[precip_cols], function(x) any(!is.na(x))))
# Formatting Helpers
format_ext_time <- function(idx) {
if (length(idx) == 0 || is.na(idx)) {
return("")
}
dt <- df$datetime[idx]
format(dt, "%d %b %H:%M")
}
format_val <- function(val, suffix = "", digits = 1, divisor = 1) {
if (is.null(val) || length(val) == 0 || is.na(val) || is.infinite(val)) {
return("—")
}
paste0(round(val / divisor, digits), suffix)
}
cards <- tagList()
add_card <- function(title, value, subtext, icon, color_class) {
cards <<- tagList(cards, div(
class = paste("glass-card", color_class),
div(class = "glass-card-icon", bsicons::bs_icon(icon)),
div(
class = "glass-card-content",
div(class = "glass-card-title", title),
div(class = "glass-card-value", value),
div(class = "glass-card-subtext", HTML(subtext))
)
))
}
if (has_temp || has_tmin || has_tmax) {
avg_t <- if (has_temp) mean(t_vals, na.rm = TRUE) else NA_real_
if (has_tmin) {
min_t <- min(tmin_vals, na.rm = TRUE)
min_idx <- which.min(tmin_vals)
} else if (has_temp) {
min_t <- min(t_vals, na.rm = TRUE)
min_idx <- which.min(t_vals)
} else {
min_t <- NA_real_
min_idx <- NA_integer_
}
if (has_tmax) {
max_t <- max(tmax_vals, na.rm = TRUE)
max_idx <- which.max(tmax_vals)
} else if (has_temp) {
max_t <- max(t_vals, na.rm = TRUE)
max_idx <- which.max(t_vals)
} else {
max_t <- NA_real_
max_idx <- NA_integer_
}
valid_idx <- which(!is.na(t_vals) | !is.na(tmax_vals) | !is.na(tmin_vals))
last_valid_idx <- tail(valid_idx, 1)
if (length(last_valid_idx) > 0) {
if (!is.na(t_vals[last_valid_idx])) {
latest <- t_vals[last_valid_idx]
} else if (!is.na(tmax_vals[last_valid_idx])) {
latest <- tmax_vals[last_valid_idx]
} else {
latest <- tmin_vals[last_valid_idx]
}
} else {
latest <- NA_real_
}
add_card(
"Temperature", format_val(latest, "°C"),
sprintf(
"Avg: %s | Min: %s (%s) | Max: %s (%s)",
format_val(avg_t, "°C"), format_val(min_t, "°C"), format_ext_time(min_idx), format_val(max_t, "°C"), format_ext_time(max_idx)
),
"thermometer-half", "card-red"
)
}
if (has_wind) {
w_col <- if ("wind_speed" %in% names(df) && any(!is.na(df$wind_speed))) "wind_speed" else "wind_gust"
if (!is.null(w_col) && any(!is.na(df[[w_col]]))) {
w_vals <- as.numeric(df[[w_col]])
w_vals[w_vals < 0] <- NA
latest <- tail(na.omit(w_vals), 1)
avg_w <- mean(w_vals, na.rm = TRUE)
max_col <- if ("wind_gust" %in% names(df) && any(!is.na(df$wind_gust))) "wind_gust" else w_col
max_vals <- as.numeric(df[[max_col]])
max_vals[max_vals < 0] <- NA
max_w <- max(max_vals, na.rm = TRUE)
max_idx <- which.max(max_vals)
add_card(
"Wind Speed", format_val(latest, " m/s"),
sprintf("Avg: %s | Max Gust: %s (%s)", format_val(avg_w, " m/s"), format_val(max_w, " m/s"), format_ext_time(max_idx)),
"wind", "card-emerald"
)
}
}
if (has_humidity) {
if ("rh" %in% names(df) && any(!is.na(df$rh))) {
h_col <- "rh"
h_vals <- as.numeric(df[[h_col]])
h_vals[h_vals < 0] <- NA
latest <- tail(na.omit(h_vals), 1)
avg_h <- mean(h_vals, na.rm = TRUE)
min_h <- min(h_vals, na.rm = TRUE)
min_idx <- which.min(h_vals)
add_card(
"Relative Humidity", format_val(latest, "%", 0),
sprintf("Avg: %s | Min: %s (%s)", format_val(avg_h, "%", 0), format_val(min_h, "%", 0), format_ext_time(min_idx)),
"droplet", "card-cyan"
)
} else if ("dew_point" %in% names(df) && any(!is.na(df$dew_point))) {
d_col <- "dew_point"
d_vals <- as.numeric(df[[d_col]])
# Dew point can be negative, no filtering
latest <- tail(na.omit(d_vals), 1)
avg_d <- mean(d_vals, na.rm = TRUE)
min_d <- min(d_vals, na.rm = TRUE)
min_idx <- which.min(d_vals)
add_card(
"Dew Point", format_val(latest, "°C"),
sprintf("Avg: %s | Min: %s (%s)", format_val(avg_d, "°C"), format_val(min_d, "°C"), format_ext_time(min_idx)),
"droplet", "card-cyan"
)
}
}
if (has_pressure) {
pr_col <- if ("pressure" %in% names(df) && any(!is.na(df$pressure))) "pressure" else if ("station_pressure" %in% names(df) && any(!is.na(df$station_pressure))) "station_pressure" else NULL
if (!is.null(pr_col) && any(!is.na(df[[pr_col]]))) {
pr_vals <- as.numeric(df[[pr_col]])
pr_vals[pr_vals < 0] <- NA
latest <- tail(na.omit(pr_vals), 1)
avg_pr <- mean(pr_vals, na.rm = TRUE)
min_pr <- min(pr_vals, na.rm = TRUE)
max_pr <- max(pr_vals, na.rm = TRUE)
min_idx <- which.min(pr_vals)
max_idx <- which.max(pr_vals)
add_card(
if (pr_col == "pressure") "Atm. Pressure" else "Station Pressure", format_val(latest, " hPa", 0),
sprintf("Avg: %s | Range: %s - %s", format_val(avg_pr, " hPa", 0), format_val(min_pr, " hPa", 0), format_val(max_pr, " hPa", 0)),
"speedometer2", "card-purple"
)
}
}
if (has_vis) {
v_vals <- as.numeric(df$vis)
v_vals[v_vals < 0] <- NA
latest <- tail(na.omit(v_vals), 1)
avg_v <- mean(v_vals, na.rm = TRUE)
add_card(
"Visibility", format_val(latest, " km", 0),
sprintf("Avg: %s", format_val(avg_v, " km", 0)),
"eye", "card-slate"
)
}
if (has_precip && "precip" %in% names(df)) {
p_vals <- as.numeric(df$precip)
p_vals[p_vals < 0] <- NA
if (any(!is.na(p_vals))) {
max_p <- max(p_vals, na.rm = TRUE)
max_idx <- which.max(p_vals)
yearly_totals <- df %>%
dplyr::mutate(p_val = p_vals) %>%
dplyr::group_by(year) %>%
dplyr::summarize(total = sum(p_val, na.rm = TRUE), .groups = "drop") %>%
dplyr::filter(!is.na(year))
yearly_str <- paste(
sapply(1:nrow(yearly_totals), function(i) {
sprintf("%s: %s", yearly_totals$year[i], format_val(yearly_totals$total[i], " mm", 0))
}),
collapse = " | "
)
latest <- tail(na.omit(p_vals), 1)
add_card(
"Precipitation", format_val(latest, " mm"),
sprintf("Max 24h Precip: %s<br/>Recorded: %s<br/>Yearly Totals: %s", format_val(max_p, " mm"), format_ext_time(max_idx), yearly_str),
"cloud-rain", "card-indigo"
)
}
}
date_range_str <- ""
if (nrow(df) > 0) {
date_range_str <- paste(
format(min(df$datetime, na.rm = TRUE), "%d %b %Y"), "-",
format(max(df$datetime, na.rm = TRUE), "%d %b %Y")
)
}
plot_list <- tagList()
if (has_temp) {
plot_list <- tagList(
plot_list,
div(
class = "col-12 col-lg-6",
plotlyOutput("temp_plot", height = "320px")
)
)
}
if (has_humidity) {
plot_list <- tagList(
plot_list,
div(
class = "col-12 col-lg-6",
plotlyOutput("humidity_plot", height = "320px")
)
)
}
if (has_wind) {
plot_list <- tagList(
plot_list,
div(
class = "col-12 col-lg-6",
plotlyOutput("wind_overview_plot", height = "320px")
)
)
}
if (has_pressure) {
plot_list <- tagList(
plot_list,
div(
class = "col-12 col-lg-6",
plotlyOutput("pressure_plot", height = "320px")
)
)
}
if (has_vis) {
plot_list <- tagList(
plot_list,
div(
class = "col-12 col-lg-6",
plotlyOutput("visibility_plot", height = "320px")
)
)
}
if (has_precip) {
plot_list <- tagList(
plot_list,
div(
class = "col-12 col-lg-6",
plotlyOutput("precip_plot", height = "320px")
)
)
}
if (has_wind_rose) {
plot_list <- tagList(
plot_list,
div(
class = "col-12 col-lg-6",
plotlyOutput("wind_rose", height = "320px")
)
)
}
tagList(
if (length(cards) > 0) {
latest_obs_str <- if (nrow(df) > 0) paste("— Latest values as of:", format_ext_time(nrow(df))) else ""
div(
class = "w-100",
div(class = "summary-cards-header", paste("Station Observations Summary", if (nchar(date_range_str) > 0) paste0("(", date_range_str, ")") else "", latest_obs_str)),
div(class = "summary-cards-container", cards)
)
} else {
NULL
},
div(
class = "row g-3",
style = "padding: 10px;",
plot_list
)
)
})
output$daily_summary_main <- DT::renderDataTable({
df <- station_data()
summary_df <- df
if (is.null(summary_df) || nrow(summary_df) == 0) {
return(NULL)
}
# Explicitly select and order columns to match the names
display_summary_df <- summary_df %>%
dplyr::mutate(date = as.Date(datetime)) %>%
dplyr::select(
date,
avg_temp = temp,
max_temp = temp_max,
min_temp = temp_min,
max_wind = wind_gust,
avg_vis = vis,
avg_pressure = pressure,
precip_sum = precip
)
DT::datatable(
display_summary_df,
colnames = unname(c(
col_names_map["date"],
col_names_map["avg_temp"],
col_names_map["max_temp"],
col_names_map["min_temp"],
col_names_map["max_wind"],
col_names_map["avg_vis"],
col_names_map["avg_pressure"],
col_names_map["precip_sum"]
)),
options = list(
pageLength = 12,
dom = "lrtip",
columnDefs = list(list(className = "dt-center", targets = "_all")),
order = list(list(0, "desc"))
),
rownames = FALSE,
selection = "none"
)
})
# Track valid map bounds (persist when map is hidden)
last_valid_bounds <- reactiveVal(NULL)
observe({
# Only update bounds if map is visible and bounds seem valid
# This prevents the table from going empty when switching tabs (map hidden -> bounds=0)
req(input$map_bounds)
if (is.null(input$main_nav) || input$main_nav == "Map View") {
b <- input$map_bounds
# Basic validity check (avoid 0-area bounds if map is hidden/collapsed)
if (!is.null(b$north) && !is.null(b$south) && b$north != b$south) {
last_valid_bounds(b)
}
}
})
# Reactive for filtering by map bounds
visible_stations <- reactive({
data <- filtered_stations()
if (is.null(data)) {
return(NULL)
}
# Use persisted bounds if available, otherwise live bounds (or all data)
bounds <- last_valid_bounds()
# Fallback to live bounds if we haven't captured any yet (edge case)
if (is.null(bounds)) {
bounds <- input$map_bounds
}
if (is.null(bounds)) {
return(data)
}
data %>%
dplyr::filter(
latitude >= bounds$south,
latitude <= bounds$north,
longitude >= bounds$west,
longitude <= bounds$east
)
})
output$plot_info <- renderUI({
info <- station_info()
if (is.null(info)) {
return(NULL)
}
tagList(h5(info$name), p(tags$small("Station ID: ", info$id)))
})
output$download_stations <- downloadHandler(
filename = function() {
paste("ssod_stations_", Sys.Date(), ".xlsx", sep = "")
},
content = function(file) {
df <- visible_stations()
if (is.null(df)) {
return(NULL)
}
write_xlsx(df, path = file)
}
)
# Export Daily Data Button
output$download_daily <- downloadHandler(
filename = function() {
st_id <- active_export_id()
d_start <- input$date_range[1]
d_end <- input$date_range[2]
if (!isTruthy(st_id)) {
return(paste0("ssod_daily_", Sys.Date(), ".xlsx"))
}
paste0("SSOD_daily_", st_id, "_", d_start, "_to_", d_end, ".xlsx")
},
content = function(file) {
df <- station_data()
summary_df <- df
if (is.null(summary_df) || nrow(summary_df) == 0) {
write_xlsx(data.frame(Message = "No data found"), path = file)
} else {
write_xlsx(summary_df, path = file)
}
}
)
}