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!
In-Memory Data: ", mem_size, "") 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
Recorded: %s
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) } } ) }