# server.R for EuroMeteo Explorer # Helper function for label generation generate_station_label <- function(name, id, country, start_date, end_date, detailed_summary, resolution_info, val = NULL, label_name = "Observation", unit = "", missing_label = NULL, missing_color = "#8A8F98") { tryCatch( { val_html <- "" if (!is.null(val) && !is.na(val)) { # Determine color based on label_name color <- if (grepl("Temperature", label_name, ignore.case = TRUE)) { if (exists("temp_to_color")) temp_to_color(val) else "#0056b3" } else if (grepl("Precipitation", label_name, ignore.case = TRUE)) { if (exists("precip_to_color")) precip_to_color(val) else "#0056b3" } else if (grepl("Pressure", label_name, ignore.case = TRUE)) { if (exists("pressure_to_color")) pressure_to_color(val) else "#0056b3" } else if (grepl("Wind Speed", label_name, ignore.case = TRUE)) { if (exists("wind_speed_to_color")) wind_speed_to_color(val) else "#0056b3" } else { "#0056b3" } val_html <- paste0( "
", "
", label_name, "
", "
", round(val, 1), "", unit, "", "
", "
" ) } else if (!is.null(missing_label) && !is.na(missing_label)) { color <- missing_color val_html <- paste0( "
", "
", label_name, "
", "
", htmltools::htmlEscape(missing_label), "
", "
" ) } paste0( "
", # Header Section "
", "
", htmltools::htmlEscape(name), "
", "
", "", htmltools::htmlEscape(country), "", "", id, "", "
", "
", # Value Section (if available) val_html, # Parameters & Status Section "
", "
", " Status & Capabilities", "
", "
", resolution_info, "
", "
", "
", "" ) }, error = function(e) { paste0("
", htmltools::htmlEscape(name), "
(", id, ")
") } ) } server <- function(input, output, session) { # --- Reactive Values --- current_station_id <- reactiveVal(NULL) station_data <- reactiveVal(NULL) # The loaded data loading_status <- reactiveVal(FALSE) loading_diagnostics <- reactiveVal("") map_fetch_token <- reactiveVal(0) map_fetch_active <- reactiveVal(FALSE) map_render_pending <- reactiveVal(FALSE) map_render_token <- reactiveVal(NULL) previous_station_choices <- reactiveVal(NULL) # Track previous choices to avoid blink url_initialized <- reactiveVal(FALSE) # Track if URL params have been applied current_resolution <- reactiveVal("Hourly") # Internal default resolution # MapLibre Reactive Values style_change_trigger <- reactiveVal(0) # Trigger station re-render after style change map_initialized <- reactiveVal(FALSE) # Track if map bounds have been set stations_before_id <- reactiveVal(NULL) # Layer ID for station ordering current_raster_layers <- reactiveVal(character(0)) # Track dynamically added raster layers basemap_debounced <- shiny::debounce(reactive(input$basemap), 200) # Track last selected department to prevent auto-zoom on resolution change last_dept <- reactiveVal("All") # Map bounds for Europe (MeteoGate) - Broadened to zoom out one level map_bounds <- list( lat_min = 30.0, lat_max = 75.0, lng_min = -25.0, lng_max = 55.0 ) station_internal_cols <- c( "station_id", "station_name", "standard_name", "value", "param_name", "level", "method", "period", "period_seconds", "method_rank", "level_rank", "temp", "rh", "wind_dir", "pressure", "pressure_sea", "pressure_station", "precip", "solar_global", "dew_point" ) get_station_display_cols <- function(df) { all_cols <- names(df) display_cols <- all_cols[!(all_cols %in% station_internal_cols)] alias_map <- attr(df, "preferred_alias_map") if (!is.null(alias_map) && length(alias_map) > 0) { alias_names <- names(alias_map) for (alias in alias_names) { if (is.null(alias) || is.na(alias) || !nzchar(alias)) next if (!(alias %in% display_cols)) next variant_col <- alias_map[[alias]] if (is.null(variant_col) || is.na(variant_col) || !nzchar(variant_col)) next if (variant_col %in% display_cols && variant_col != alias) { display_cols <- setdiff(display_cols, alias) } } } # Remove columns that are true value-duplicates over the current selection. # Keep the one with the shortest name (usually the most general/clean one). display_cols <- display_cols[order(nchar(display_cols))] kept_cols <- character(0) for (col in display_cols) { if (col == "datetime") { kept_cols <- c(kept_cols, col) next } is_duplicate <- FALSE for (prev_col in kept_cols) { if (prev_col == "datetime") next v1 <- df[[col]] v2 <- df[[prev_col]] if (length(v1) != length(v2)) next same <- if (is.numeric(v1) && is.numeric(v2)) { (is.na(v1) & is.na(v2)) | (!is.na(v1) & !is.na(v2) & abs(v1 - v2) <= 1e-9) } else { (is.na(v1) & is.na(v2)) | (!is.na(v1) & !is.na(v2) & as.character(v1) == as.character(v2)) } if (all(same)) { is_duplicate <- TRUE break } } if (!is_duplicate) { kept_cols <- c(kept_cols, col) } } # Restore original order but filter to kept ones final_cols <- all_cols[all_cols %in% kept_cols] display_cols <- final_cols[final_cols %in% display_cols] # Apply custom weather column sorting (Grouping and Precipitation order) display_cols <- sort_weather_columns(display_cols) display_cols } format_wind_speed_decimals <- function(df, digits = 1) { if (is.null(df) || nrow(df) == 0) { return(df) } wind_cols <- names(df)[grepl("^(wind_speed|wind_gust|wind_speed_of_gust)", names(df))] if (length(wind_cols) == 0) { return(df) } for (col in wind_cols) { if (is.numeric(df[[col]])) { df[[col]] <- round(df[[col]], digits) } } df } queue_map_render <- function(message = "Plotting stations...") { if (!isTRUE(isolate(map_render_pending()))) { return() } token <- isolate(map_render_token()) if (is.null(token)) { map_render_pending(FALSE) return() } session$sendCustomMessage("awaitMapRender", list( mapId = "map", token = token, text = message )) } 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 (sometimes used in different versions) "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 label names (for compatibility) "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" ) # Layers to hide in Satellite (Hybrid) mode to let the raster shows through # This includes all non-symbol layers (background, water, roads, boundaries, etc.) 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) { # Layer may not exist in current style, ignore silently } ) } } # Helper: Parse URL query string parse_url_params <- function(query_string) { if (is.null(query_string) || query_string == "" || query_string == "?") { return(list()) } qs <- sub("^\\?", "", query_string) if (qs == "") { return(list()) } pairs <- strsplit(qs, "&")[[1]] params <- list() for (pair in pairs) { kv <- strsplit(pair, "=")[[1]] if (length(kv) == 2) { params[[kv[1]]] <- URLdecode(kv[2]) } } params } # Helper: Broadcast current state to parent page broadcast_state <- function(view_override = NULL) { # Get scalar values from current selection sid <- current_station_id() st_meta <- NULL if (!is.null(sid)) { # Use all_stations() to lookup metadata # We use isolate to prevent cyclic dependencies if called from an observer # asking for this data all <- isolate(all_stations()) if (!is.null(all)) { st_meta <- all %>% filter(id == sid) } } station_id <- if (!is.null(st_meta) && nrow(st_meta) > 0) as.character(st_meta$id[1]) else NULL station_name <- if (!is.null(st_meta) && nrow(st_meta) > 0) as.character(st_meta$name[1]) else NULL s_country <- if (!is.null(st_meta) && nrow(st_meta) > 0) { as.character(st_meta$country[1]) } else if (!is.null(input$country_selector) && length(input$country_selector) > 0 && !("All Countries" %in% input$country_selector)) { paste(input$country_selector, collapse = ", ") } else { NULL } resolution <- current_resolution() main_tab <- 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 <- input$dashboard_subtabs if (!is.null(subtab) && subtab == "Data") { "dashboard-data" } else { "dashboard-plots" } } else { "map" } } else { "map" } session$sendCustomMessage("updateParentURL", list( station = station_name, # Use Name instead of ID for URL stationName = station_name, country = s_country, resolution = resolution, view = view, view = view, parameter = input$map_parameter, hours_ago = if (!is.null(input$time_slider)) input$time_slider else 0, time_label = if (!is.null(input$time_slider)) { target_time <- Sys.time() - (input$time_slider * 3600) format(target_time, "%Y-%m-%d %H:00 UTC", tz = "UTC") } else { format(Sys.time(), "%Y-%m-%d %H:00 UTC", tz = "UTC") } )) # Update the URL in the address bar (for standalone usage) hours_val <- if (!is.null(input$time_slider)) input$time_slider else 0 query_parts <- c() if (!is.null(station_name)) query_parts <- c(query_parts, paste0("station=", URLencode(station_name))) if (!is.null(s_country)) query_parts <- c(query_parts, paste0("country=", URLencode(s_country))) query_parts <- c( query_parts, paste0("resolution=", URLencode(resolution)), paste0("view=", URLencode(view)), paste0("parameter=", URLencode(if (is.null(input$map_parameter)) "air_temperature" else input$map_parameter)) ) query_string <- paste0("?", paste(query_parts, collapse = "&")) updateQueryString(query_string, mode = "push") } # Observer: Apply URL params on app startup observe({ req(!url_initialized()) query <- session$clientData$url_search params <- parse_url_params(query) if (length(params) == 0) { url_initialized(TRUE) return() } # Apply resolution if (!is.null(params$resolution) && params$resolution %in% c("Hourly", "Daily", "6-min", "Monthly")) { current_resolution(params$resolution) } # Apply parameter if (!is.null(params$parameter)) { updateSelectInput(session, "map_parameter", selected = params$parameter) } # Apply hours_ago if (!is.null(params$hours_ago)) { val <- as.numeric(params$hours_ago) if (!is.na(val) && val >= 0 && val <= 23) { updateSliderInput(session, "time_slider", value = val) } } # Apply date range - REMOVED for real-time focus # Apply country filter from URL if (!is.null(params$country)) { shinyjs::delay(300, { selected_country <- strsplit(params$country, ",")[[1]][1] updateSelectizeInput(session, "country_selector", selected = trimws(selected_country)) }) } # Apply station selection if (!is.null(params$station)) { station_ref <- params$station # We need to wait for stations to be available # We'll rely on the fact that existing observers handle ID updates # Try to find station in all_stations and set it # Defer slightly to ensure data is loaded shinyjs::delay(500, { st <- isolate(all_stations()) if (!is.null(st)) { # Match by ID or Name match <- st %>% filter(id == station_ref | name == station_ref) if (nrow(match) > 0) { target_id <- match$id[1] current_station_id(target_id) updateSelectizeInput(session, "station_selector", selected = target_id) # Trigger fetch logic if needed, though observers on 'current_station_id' or input should handle fetch_trigger(fetch_trigger() + 1) } } }) } # Apply view/tab navigation if (!is.null(params$view)) { view <- params$view shinyjs::delay(800, { if (view == "map") { nav_select("main_nav", "Map View") } else if (view == "station-info") { nav_select("main_nav", "Stations Info") } else if (view %in% c("dashboard-plots", "dashboard-data")) { nav_select("main_nav", "Dashboard") if (view == "dashboard-data") { shinyjs::delay(200, { # Robust switch to Data tab shinyjs::runjs("$('#dashboard_subtabs a[data-value=\"Data\"]').tab('show');") }) } } }) } url_initialized(TRUE) }) # Observer: Broadcast State Changes observe({ input$station_selector input$main_nav input$dashboard_subtabs current_station_id() input$map_parameter input$time_slider input$country_selector # Debounce slightly to avoid rapid-fire updates? # Or just rely on reactive flush. # Check initialization req(url_initialized()) # Broadcast broadcast_state() }) # Default Date Range Init - REMOVED current_index <- reactive({ NULL }) all_stations <- reactive({ # Load MeteoGate Stations get_meteogate_stations() }) # --- Freeze UI During Data Loading --- # Disable all interactive controls (except map hovering) while parsing data observe({ is_loading <- loading_status() # List of input IDs to disable/enable inputs_to_toggle <- c( "station_selector", "country_selector", "date_range", "zoom_home", "main_nav", "download_hourly" ) if (is_loading) { for (inp in inputs_to_toggle) { shinyjs::disable(inp) } } else { for (inp in inputs_to_toggle) { shinyjs::enable(inp) } } }) # Initialize / Update Country Selector Choices based on Data Availability observe({ st <- all_stations() req(st) # Get Current Observation Data to filter countries obs <- viewport_obs_data() # Calculate available countries # If we have data, filter strictly to countries present in the data # If data is NULL (loading/error), fall back to all countries to avoid empty list during transitions country_list <- if (!is.null(obs) && nrow(obs) > 0) { # Filter stations that have data ids_with_data <- unique(obs$station_id) visible_stations <- st %>% filter(id %in% ids_with_data) sort(unique(visible_stations$country)) } else { # Fallback sort(unique(st$country)) } # Add 'All Countries' as the first option countries <- c("All Countries", country_list) # Update input (preserve selection if possible) # Use isolate() to avoid reactive dependency on the selector itself (prevents blink loop) current_sel <- isolate(input$country_selector) updateSelectizeInput(session, "country_selector", choices = countries, selected = current_sel, server = TRUE) }) # Auto-Zoom on Country Selection (Zoom Only, No Filter) observeEvent(input$country_selector, { req(all_stations()) # Use all_stations to calculate bounds for the selected countries # We do NOT filter the main data, just finding the bounds to zoom to. df <- all_stations() if (!is.null(input$country_selector) && length(input$country_selector) > 0 && !("All Countries" %in% input$country_selector)) { df_subset <- df %>% filter(country %in% input$country_selector) if (nrow(df_subset) > 0) { lons <- range(df_subset$longitude, na.rm = TRUE) lats <- range(df_subset$latitude, na.rm = TRUE) if (nrow(df_subset) == 1) { maplibre_proxy("map") %>% fly_to(center = c(df_subset$longitude[1], df_subset$latitude[1]), zoom = 12) } else { maplibre_proxy("map") %>% fit_bounds(c(lons[1], lats[1], lons[2], lats[2]), animate = TRUE) } } } else { # All Countries -> Zoom to Default Bounds maplibre_proxy("map") %>% fit_bounds( c(map_bounds$lng_min, map_bounds$lat_min, map_bounds$lng_max, map_bounds$lat_max), animate = TRUE ) } }, ignoreInit = TRUE ) # Filtered stations based on Sidebar inputs filtered_stations <- reactive({ req(all_stations(), current_resolution()) df <- all_stations() res <- tolower(current_resolution()) # Filter by Country - REMOVED (Zoom only) # if (!is.null(input$country_selector) && length(input$country_selector) > 0) { # df <- df %>% filter(country %in% input$country_selector) # } # 3. Create Dynamic Hover Info (Resolution + Period Aware) # MeteoGate uses 'available_params' column df <- df %>% mutate( st_params = if ("available_params" %in% names(.)) available_params else NA_character_, resolution_info = purrr::map2_chr(end_date, st_params, function(ed, p) { p_text <- get_resolution_params(res, p) paste0( ifelse(as.Date(ed) >= Sys.Date() - 7, "Active Now", "Historical Data Only"), "
Available:
", p_text ) }) ) # Defensive: atomic data.frame to prevent tibble/rowwise issues as.data.frame(df) }) filtered_stations_sf <- reactive({ df <- filtered_stations() req(df) if (nrow(df) == 0) { return(sf::st_as_sf(df, coords = c("longitude", "latitude"), crs = 4326, remove = FALSE)) } sf::st_as_sf(df, coords = c("longitude", "latitude"), crs = 4326, remove = FALSE) }) # Render Time Slider dynamically to ensure correct initial value output$time_slider_ui <- renderUI({ # If minute < 30, data for current hour might not be ready, so default to 1 hour ago initial_minute <- as.numeric(format(Sys.time(), "%M")) initial_val <- if (initial_minute < 30) 1 else 0 sliderInput( "time_slider", label = NULL, min = 0, max = 23, value = initial_val, step = 1, ticks = TRUE, width = "100%" ) }) # Initialize / Update Station Selector Choices observe({ df <- filtered_stations() req(df) # Create choices: "Station Name (ID)" = "ID" # Defensive: Ensure atomic vectors ids <- as.character(df$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) prev_choices <- previous_station_choices() new_ids <- sort(unname(new_choices)) prev_ids <- if (!is.null(prev_choices)) sort(unname(prev_choices)) else NULL if (is.null(prev_ids) || !identical(new_ids, prev_ids)) { # Preserve selection if still in filtered list current_sel <- input$station_selector updateSelectizeInput(session, "station_selector", choices = new_choices, selected = current_sel, server = TRUE ) previous_station_choices(new_choices) } }) # --- Selection Logic via Dropdown --- observeEvent(input$station_selector, { req(input$station_selector) id_val <- input$station_selector # Find station details # Check filtered_stations first to get resolution_info s_meta <- filtered_stations() %>% filter(id == id_val) if (nrow(s_meta) == 0) { # Fallback to all_stations if not in current filter s_meta <- all_stations() %>% filter(id == id_val) # Add dummy resolution_info if missing if (!("resolution_info" %in% names(s_meta))) { s_meta$resolution_info <- "Data status unavailable for current filter" } } req(nrow(s_meta) > 0) lat_val <- s_meta$latitude[1] lng_val <- s_meta$longitude[1] # Set ID # Optimization: If ID is same as current, do not re-trigger date resets or map moves # This prevents circular loops where Date Filter -> Filtered List -> Update Selector -> Trigger Observer -> Reset Date prev_id <- current_station_id() if (!is.null(prev_id) && prev_id == id_val) { return() } current_station_id(id_val) # Try to get temperature from view session obs <- viewport_obs_data() match <- if (!is.null(obs)) obs %>% filter(station_id == id_val) else NULL current_temp <- if (!is.null(match) && nrow(match) > 0) match$temp[1] else NULL current_param_name <- if (!is.null(match) && nrow(match) > 0 && "param_name" %in% names(match)) { match$param_name[1] } else { NA_character_ } # Generate label for highlight current_param <- isolate(input$map_parameter) if (is.null(current_param)) current_param <- "air_temperature" param_label <- if (current_param == "precipitation_amount") "Precipitation" else "Air Temperature" param_unit <- if (current_param == "precipitation_amount") "mm" else "°C" missing_label <- if (current_param == "precipitation_amount") "No precip available at this hour" else NULL res_info <- if ("resolution_info" %in% names(s_meta)) s_meta$resolution_info[1] else "Data status unavailable" if (is.null(res_info) || is.na(res_info)) res_info <- "Data status unavailable for current filter" label_name <- if (current_param == "precipitation_amount") { format_precip_label(current_param_name, param_label) } else { param_label } lbl <- generate_station_label( s_meta$name[1], s_meta$id[1], s_meta$country[1], s_meta$start_date[1], s_meta$end_date[1], s_meta$detailed_summary[1], res_info, val = current_temp, label_name = label_name, unit = param_unit, missing_label = missing_label ) # Highlight & Zoom using helper highlight_selected_station(maplibre_proxy("map"), lng_val, lat_val, lbl) # NOTE: Date reset removed to preserve user context }) start_map_fetch <- function(message) { if (isTRUE(isolate(loading_status()))) { return(NULL) } token <- isolate(map_fetch_token()) + 1 map_fetch_token(token) map_fetch_active(TRUE) map_render_pending(TRUE) map_render_token(token) session$sendCustomMessage("freezeUI", list(text = message, allowCancel = FALSE)) token } end_map_fetch <- function(token) { if (is.null(token)) { return() } if (isolate(map_fetch_token()) != token) { return() } map_fetch_active(FALSE) if (!isTRUE(isolate(loading_status())) && !isTRUE(isolate(map_render_pending()))) { session$sendCustomMessage("unfreezeUI", list()) } } unfreeze_ui_if_idle <- function() { if (!isTRUE(isolate(loading_status())) && !isTRUE(isolate(map_fetch_active())) && !isTRUE(isolate(map_render_pending()))) { session$sendCustomMessage("unfreezeUI", list()) } } observeEvent(input$map_rendered, { req(input$map_rendered) token <- suppressWarnings(as.numeric(input$map_rendered$token)) current_token <- isolate(map_render_token()) if (is.na(token) || is.null(current_token) || token != current_token) { return() } map_render_pending(FALSE) if (!isTRUE(isolate(loading_status())) && !isTRUE(isolate(map_fetch_active()))) { session$sendCustomMessage("unfreezeUI", list()) } }, ignoreInit = TRUE ) # Viewport temperature data (shared by map + sidebar) viewport_obs_data <- reactiveVal(NULL) observe({ df <- filtered_stations() req(df, current_resolution(), input$map_parameter, !is.null(input$time_slider)) hours_ago <- input$time_slider # input$map_parameter already returns the value like "air_temperature" or "precipitation_amount" param_slug <- input$map_parameter selected_param_ui <- case_when( param_slug == "air_temperature" ~ "Air Temperature", param_slug == "precipitation_amount" ~ "Precipitation", param_slug == "air_pressure_at_mean_sea_level" ~ "Sea Level Pressure", param_slug == "wind_speed" ~ "Wind Speed", TRUE ~ "Data" ) # Calculate bounds from ALL filtered stations to prevent re-fetching/re-drawing on pan/zoom # This renders all stations (approx 5000-6000), which is fine for MapLibre if (nrow(df) > 0) { lats <- range(df$latitude, na.rm = TRUE) lons <- range(df$longitude, na.rm = TRUE) # Add small buffer bounds <- list( lat_min = lats[1] - 0.1, lat_max = lats[2] + 0.1, lng_min = lons[1] - 0.1, lng_max = lons[2] + 0.1 ) } else { if (!isTRUE(map_initialized())) { viewport_obs_data(NULL) return() } bounds <- map_bounds } time_label <- if (hours_ago == 0) "current hour" else paste0(hours_ago, " hour(s) ago") map_fetch_message <- paste0("Fetching map ", selected_param_ui, " (", time_label, ")...") fetch_token <- start_map_fetch(map_fetch_message) if (is.null(fetch_token)) { return() } current_session <- shiny::getDefaultReactiveDomain() later::later(function() { shiny::withReactiveDomain(current_session, { if (isolate(map_fetch_token()) != fetch_token) { return() } result <- tryCatch( get_observations_for_viewport( zoom = 10, # Force high zoom logic (no decimation) bounds = bounds, hours_ago = hours_ago, stations = df, param_name = param_slug ), error = function(e) NULL ) if (isolate(map_fetch_token()) != fetch_token) { return() } viewport_obs_data(result) end_map_fetch(fetch_token) }) }, delay = 0.05) }) # Station Count output$station_count_filtered <- renderText({ n <- nrow(filtered_stations()) paste(n, "stations found") }) # Temperature data info output$temp_data_info <- renderUI({ hours_ago <- if (!is.null(input$time_slider)) input$time_slider else return(NULL) obs_data <- viewport_obs_data() if (is.null(obs_data) || nrow(obs_data) == 0) { return(tags$small(style = "color: #888;", "No temperature data available in view")) } # Get the average observation time for this hour avg_time <- mean(obs_data$datetime, na.rm = TRUE) time_str <- format(avg_time, "%Y-%m-%d %H:%M UTC") div( style = "font-size: 0.85em; color: #555; margin-top: 5px;", tags$span( style = "display: flex; align-items: center; gap: 5px;", bsicons::bs_icon("thermometer-half", size = "1em"), paste0("Showing: ", nrow(obs_data), " stations") ), div(style = "margin-left: 20px; font-weight: 500;", time_str), tags$small( style = "color: #888;", if (hours_ago == 0) "Current observations" else paste0(hours_ago, " hour(s) ago") ) ) }) # Time slider label output$selected_time_label <- renderText({ hours_ago <- if (!is.null(input$time_slider)) input$time_slider else return("") target_time <- Sys.time() - (hours_ago * 3600) format(target_time, "%Y-%m-%d %H:00 UTC", tz = "UTC") }) # --- Map --- output$map <- renderMaplibre({ maplibre( style = "https://tiles.openfreemap.org/styles/positron", center = c(10, 50), zoom = 4 ) %>% add_navigation_control(show_compass = FALSE, visualize_pitch = FALSE, position = "top-left") }) # Initialize map bounds only once observe({ req(!map_initialized()) req(input$map_zoom) maplibre_proxy("map") %>% fit_bounds( c(map_bounds$lng_min, map_bounds$lat_min, map_bounds$lng_max, map_bounds$lat_max) ) map_initialized(TRUE) }) # 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) # Calculate geographic extent lon_span <- lons[2] - lons[1] lat_span <- lats[2] - lats[1] # 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 if (lon_span > 60 || lat_span > 40) { # Stations are globally distributed (former colonies) - use world view # Center on France but zoom out to see the whole world maplibre_proxy("map") %>% fly_to(center = c(2.2137, 20), zoom = 2) } else { maplibre_proxy("map") %>% fit_bounds(c(lons[1], lats[1], lons[2], lats[2]), animate = TRUE) } } }) # Fix Map Rendering on Tab Switch observeEvent(input$main_nav, { if (input$main_nav == "Map View") { # Slight delay to ensure tab is visible - MapLibre handles resize automatically shinyjs::runjs(" setTimeout(function() { var map = document.getElementById('map'); if (map && map.__mapgl) { map.__mapgl.resize(); } }, 200); ") } }) # --- Basemap Switching --- observeEvent(basemap_debounced(), { basemap <- basemap_debounced() proxy <- maplibre_proxy("map") if (basemap %in% c("ofm_positron", "ofm_bright")) { # VECTOR LOGIC (OpenFreeMap styles) style_url <- switch(basemap, "ofm_positron" = "https://tiles.openfreemap.org/styles/positron", "ofm_bright" = "https://tiles.openfreemap.org/styles/bright" ) proxy %>% set_style(style_url, preserve_layers = FALSE) # For OpenFreeMap sandwich, we want stations below labels stations_before_id("waterway_line_label") current_session <- shiny::getDefaultReactiveDomain() selected_basemap <- basemap later::later(function() { shiny::withReactiveDomain(current_session, { # Race condition check current_basemap <- isolate(input$basemap) if (current_basemap != selected_basemap) { return() } # Trigger re-render of stations and apply label visibility 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") { # HYBRID LOGIC (EOX Sentinel-2 Satellite + OpenFreeMap Labels/Roads) # Use Positron as the base for labels/roads proxy %>% set_style(ofm_positron_style, preserve_layers = FALSE) current_session <- shiny::getDefaultReactiveDomain() selected_basemap <- basemap later::later(function() { 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) # Add Sentinel layer at the very bottom (before 'background') # Actually, we'll hide background and put it just above it if needed, # but 'background' is typically the absolute first. 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" ) # Hide all non-label vector layers so only satellite + symbols show for (layer_id_kill in non_label_layer_ids) { tryCatch( { maplibre_proxy("map") %>% set_layout_property(layer_id_kill, "visibility", "none") }, error = function(e) { # Layer might not exist, ignore } ) } # Ensure labels according to user preference apply_label_visibility(maplibre_proxy("map"), isolate(input$show_labels)) # Stations should be above the satellite layer # In Positron, waterway_line_label is the lowest symbol. # Our stations are already set to render before waterway_line_label if the ID exists. stations_before_id("waterway_line_label") style_change_trigger(isolate(style_change_trigger()) + 1) }) }, delay = 0.5) } }) # Toggle Labels visibility observeEvent(input$show_labels, { apply_label_visibility(maplibre_proxy("map"), input$show_labels) }, ignoreInit = TRUE ) observeEvent(current_resolution(), { station_data(NULL) parsed_data_list(list()) # Only block UI if we have a station selected (fetch will occur) if (!is.null(current_station_id())) { loading_status(TRUE) } }, priority = 1000, ignoreInit = TRUE ) # Update Markers observe({ df <- filtered_stations_sf() req(df, current_resolution()) # Wait for map to be ready before adding markers req(map_initialized()) # Add dependency on style_change_trigger to re-add layer after style change style_change_trigger() # Get current parameter for labels/filters current_param <- isolate(input$map_parameter) if (is.null(current_param)) current_param <- "air_temperature" is_precip <- current_param == "precipitation_amount" is_pressure <- current_param == "air_pressure_at_mean_sea_level" is_wind <- current_param == "wind_speed" is_wind <- current_param == "wind_speed" param_label <- case_when( is_precip ~ "Precipitation", is_pressure ~ "Sea Level Pressure", is_wind ~ "Wind Speed", TRUE ~ "Air Temperature" ) param_unit <- case_when( is_precip ~ "mm", is_pressure ~ "hPa", is_wind ~ "m/s", TRUE ~ "°C" ) missing_label <- case_when( is_precip ~ "No precip available at this hour", is_pressure ~ "No pressure available", is_wind ~ "No wind available", TRUE ~ NA_character_ ) # Fetch observations for the selected hour with zoom-based sampling obs_data <- viewport_obs_data() if (!is.null(obs_data) && nrow(obs_data) > 0) { # Merge temperature data with stations df <- df %>% left_join(obs_data, by = c("id" = "station_id")) if (!"param_name" %in% names(df)) { df$param_name <- NA_character_ } } else { df$temp <- NA_real_ df$param_name <- NA_character_ if (!is_precip) { # If no observations available for temperature, show nothing maplibre_proxy("map") %>% clear_layer("stations") queue_map_render() return() } } if (!is_precip) { # Filter to only stations with temperature data df <- df %>% filter(!is.na(temp)) } if (nrow(df) == 0) { maplibre_proxy("map") %>% clear_layer("stations") queue_map_render() return() } if (is_precip) { df <- df %>% mutate(label_name = format_precip_label(param_name, param_label)) } else { df$label_name <- param_label } use_detailed_tooltips <- isTRUE(input$detailed_tooltips) if (use_detailed_tooltips) { # Full hover cards (slower) df <- df %>% mutate( tooltip_content = purrr::pmap_chr( list(name, id, country, start_date, end_date, detailed_summary, resolution_info, temp, label_name), function(name, id, country, start_date, end_date, detailed_summary, resolution_info, val, label_name) { generate_station_label(name, id, country, start_date, end_date, detailed_summary, resolution_info, val = val, label_name = label_name, unit = param_unit, missing_label = missing_label ) } ) ) } else { # Lightweight hover cards for faster rendering label_name_vec <- df$label_name value_text <- if (is_precip) { fallback_text <- if (!is.null(missing_label) && !is.na(missing_label)) missing_label else "No data" ifelse(!is.na(df$temp), paste0(round(df$temp, 1), " ", param_unit), fallback_text) } else { paste0(round(df$temp, 1), " ", param_unit) } df$tooltip_content <- paste0( "
", "
", htmltools::htmlEscape(df$name), "
", "
", htmltools::htmlEscape(df$country), "
", "
", htmltools::htmlEscape(label_name_vec), ": ", htmltools::htmlEscape(value_text), "
", "
" ) } if (nrow(df) > 0) { # Sort by value (higher values drawn on top) df <- df %>% arrange(!is.na(temp), temp) # Replace NA temps with sentinel for MapLibre expression df$temp[is.na(df$temp)] <- -999 # Build MapLibre step expression for client-side coloring color_expr <- get_maplibre_color_expr(current_param, "temp") # Trim to only columns MapLibre needs (reduces serialization time) map_data <- df %>% select(id, name, country, temp, tooltip_content, geometry) before_layer_id <- isolate(stations_before_id()) # Add thin border for precipitation to distinguish light dots from map stroke_width <- if (current_param == "precipitation_amount") 0.5 else 0 maplibre_proxy("map") %>% clear_layer("stations") %>% add_circle_layer( id = "stations", source = map_data, circle_color = color_expr, circle_radius = 7, circle_stroke_color = "#333333", circle_stroke_width = stroke_width, circle_opacity = 0.8, tooltip = get_column("tooltip_content"), before_id = before_layer_id ) # Re-highlight selected station if present (to update label with new resolution) sid <- isolate(current_station_id()) if (!is.null(sid)) { sel_row <- df %>% filter(id == sid) if (nrow(sel_row) > 0) { # Restore NA for display sel_temp <- if (sel_row$temp[1] <= -999) NA_real_ else sel_row$temp[1] sel_label <- generate_station_label( sel_row$name[1], sel_row$id[1], sel_row$country[1], sel_row$start_date[1], sel_row$end_date[1], sel_row$detailed_summary[1], sel_row$resolution_info[1], val = sel_temp, label_name = sel_row$label_name[1], unit = param_unit, missing_label = missing_label ) highlight_selected_station(maplibre_proxy("map"), sel_row$longitude[1], sel_row$latitude[1], sel_label, move_map = FALSE) } else { maplibre_proxy("map") %>% clear_layer("selected-highlight") } } queue_map_render() } }) # Centralized Selection Highlight Observer # Ensures the red highlight circle and its hover label are always in sync with current data/filters observe({ id_val <- current_station_id() if (is.null(id_val)) { maplibre_proxy("map") %>% clear_layer("selected-highlight") return() } # Get meta from filtered_stations if possible s_meta <- filtered_stations() %>% filter(id == id_val) if (nrow(s_meta) == 0) { s_meta <- all_stations() %>% filter(id == id_val) } # Skip if no meta found at all req(nrow(s_meta) > 0) # Try to get temperature from view session obs <- viewport_obs_data() match <- if (!is.null(obs)) obs %>% filter(station_id == id_val) else NULL current_temp <- if (!is.null(match) && nrow(match) > 0) match$temp[1] else NULL current_param_name <- if (!is.null(match) && nrow(match) > 0 && "param_name" %in% names(match)) { match$param_name[1] } else { NA_character_ } # Generate Label current_param <- isolate(input$map_parameter) if (is.null(current_param)) current_param <- "air_temperature" is_precip <- current_param == "precipitation_amount" is_pressure <- current_param == "air_pressure_at_mean_sea_level" is_wind <- current_param == "wind_speed" is_wind <- current_param == "wind_speed" param_label <- case_when( is_precip ~ "Precipitation", is_pressure ~ "Sea Level Pressure", is_wind ~ "Wind Speed", TRUE ~ "Air Temperature" ) param_unit <- case_when( is_precip ~ "mm", is_pressure ~ "hPa", is_wind ~ "m/s", TRUE ~ "°C" ) missing_label <- case_when( is_precip ~ "No precip available at this hour", is_pressure ~ "No pressure available", is_wind ~ "No wind available", TRUE ~ NA_character_ ) res_info <- if ("resolution_info" %in% names(s_meta)) s_meta$resolution_info[1] else "Data status unavailable" if (is.null(res_info) || is.na(res_info)) res_info <- "Data status unavailable for current filter" label_name <- if (current_param == "precipitation_amount") { format_precip_label(current_param_name, param_label) } else { param_label } lbl <- generate_station_label( s_meta$name[1], s_meta$id[1], s_meta$country[1], s_meta$start_date[1], s_meta$end_date[1], s_meta$detailed_summary[1], res_info, val = current_temp, label_name = label_name, unit = param_unit, missing_label = missing_label ) # Update Highlight Marker highlight_selected_station(maplibre_proxy("map"), s_meta$longitude[1], s_meta$latitude[1], lbl, move_map = FALSE) }) # --- Selection Logic --- observeEvent(input$map_feature_click, { clicked_data <- input$map_feature_click # Check if the click was on the "stations" layer if (!is.null(clicked_data) && (isTRUE(clicked_data$layer_id == "stations") || isTRUE(clicked_data$layer == "stations"))) { id_val <- clicked_data$properties$id # Set ID current_station_id(id_val) # Highlight & Zoom explicitly (to ensure animation) s_meta <- filtered_stations() %>% filter(id == id_val) if (nrow(s_meta) == 0) { s_meta <- all_stations() %>% filter(id == id_val) } if (nrow(s_meta) > 0) { # Try to get temperature current_temp <- clicked_data$properties$temp current_param_name <- clicked_data$properties$param_name if (is.null(current_temp) || is.null(current_param_name) || is.na(current_param_name)) { obs <- viewport_obs_data() match <- if (!is.null(obs)) obs %>% filter(station_id == id_val) else NULL if (is.null(current_temp) && !is.null(match) && nrow(match) > 0) { current_temp <- match$temp[1] } if ((is.null(current_param_name) || is.na(current_param_name)) && !is.null(match) && nrow(match) > 0 && "param_name" %in% names(match)) { current_param_name <- match$param_name[1] } } if (is.null(current_param_name)) { current_param_name <- NA_character_ } res_info <- if ("resolution_info" %in% names(s_meta)) s_meta$resolution_info[1] else "Data status unavailable" if (is.null(res_info) || is.na(res_info)) res_info <- "Data status unavailable for current filter" current_param <- isolate(input$map_parameter) if (is.null(current_param)) current_param <- "air_temperature" param_label <- if (current_param == "precipitation_amount") "Precipitation" else "Air Temperature" param_unit <- if (current_param == "precipitation_amount") "mm" else "°C" missing_label <- if (current_param == "precipitation_amount") "No precip available at this hour" else NULL label_name <- if (current_param == "precipitation_amount") { format_precip_label(current_param_name, param_label) } else { param_label } lbl <- generate_station_label( s_meta$name[1], s_meta$id[1], s_meta$country[1], s_meta$start_date[1], s_meta$end_date[1], s_meta$detailed_summary[1], res_info, val = current_temp, label_name = label_name, unit = param_unit, missing_label = missing_label ) highlight_selected_station(maplibre_proxy("map"), s_meta$longitude[1], s_meta$latitude[1], lbl, move_map = TRUE) } # Force fetch fetch_trigger(fetch_trigger() + 1) # Sync dropdown updateSelectizeInput(session, "station_selector", selected = id_val) } }) # --- Immediate State Clearing to Prevent Main Thread Blocking --- # When inputs change, clear data IMMEDIATELY to prevent plots from # rendering old data with new settings (e.g. Daily data -> Monthly plots) # This covers the 500ms debounce gap and prevents app freezing on HuggingFace free tier. observeEvent(current_resolution(), { station_data(NULL) parsed_data_list(list()) # Only block UI if we have a station selected (fetch will occur) if (!is.null(current_station_id())) { loading_status(TRUE) } }, priority = 1000, ignoreInit = TRUE ) observeEvent(current_station_id(), { station_data(NULL) parsed_data_list(list()) # Only block UI if a valid station is selected if (!is.null(current_station_id())) { loading_status(TRUE) } }, priority = 1000, ignoreInit = TRUE ) # --- Async Fetching State Machine --- # State Variables fetch_stage <- reactiveVal(0) # 0=Idle, 1=Init, 2=NextFile, 3=Head, 4=Download, 5=Parse, 6=Merge fetch_message <- reactiveVal("Idle") fetch_queue <- reactiveVal(list()) # List of targets (url, etc.) fetch_queue_idx <- reactiveVal(0) parsed_data_list <- reactiveVal(list()) # Accumulate dataframes # Progress Tracking fetch_total_size <- reactiveVal(0) fetch_current_pos <- reactiveVal(0) fetch_current_token <- reactiveVal(NULL) # To invalidate stale sessions fetch_temp_file <- reactiveVal(NULL) fetch_trigger <- reactiveVal(0) # Counter to force fetch fetch_last_processed_request <- reactiveVal(NULL) # Track last processed request (list) to detect new requests # Fetch timing (lower values = faster response after click) fetch_debounce_ms <- 150 fetch_ui_flush_delay <- 0.05 # Short-lived in-session cache to speed up repeat station clicks station_cache_ttl <- 600 # 10 minutes (matches data update cycle) station_cache_max <- 20 station_cache <- reactiveVal(list()) cache_key_for <- function(station_id, resolution) { paste0(station_id, "|", resolution) } get_cached_station <- function(key) { cache <- isolate(station_cache()) entry <- cache[[key]] if (is.null(entry) || is.null(entry$ts) || is.null(entry$df)) { return(NULL) } age <- as.numeric(difftime(Sys.time(), entry$ts, units = "secs")) if (!is.finite(age) || age > station_cache_ttl) { cache[[key]] <- NULL station_cache(cache) return(NULL) } entry$df } set_cached_station <- function(key, df) { cache <- isolate(station_cache()) cache[[key]] <- list(ts = Sys.time(), df = df) if (length(cache) > station_cache_max) { times <- vapply(cache, function(x) as.numeric(x$ts), numeric(1)) drop_n <- length(cache) - station_cache_max drop_keys <- names(sort(times))[seq_len(drop_n)] if (length(drop_keys) > 0) { cache[drop_keys] <- NULL } } station_cache(cache) } # Initial Trigger (Debounced Window) window_reactive <- reactive({ # Rolling 24-hour window (matches current MeteoGate availability) ft <- fetch_trigger() # Round to previous 10 minutes to stabilize API keys and avoid abuse # MeteoGate stations typically report every 10 minutes end_t <- lubridate::floor_date(Sys.time(), "10 minutes") start_t <- end_t - (24 * 3600) list(id = current_station_id(), start = start_t, end = end_t, trigger = ft) }) window_debounced <- window_reactive %>% debounce(fetch_debounce_ms) # Helper to get current station name get_station_name <- function() { sid <- current_station_id() if (is.null(sid)) { return("") } st <- all_stations() %>% filter(id == sid) if (nrow(st) > 0) st$name[1] else sid } reset_fetch <- function(msg = NULL) { fetch_stage(0) # Invalidate token to kill pending async tasks fetch_current_token(as.numeric(Sys.time())) loading_status(FALSE) if (!is.null(msg)) loading_diagnostics(msg) tmp <- fetch_temp_file() if (!is.null(tmp) && file.exists(tmp)) unlink(tmp) fetch_temp_file(NULL) parsed_data_list(list()) # Unfreeze UI when fetch is reset/cancelled unfreeze_ui_if_idle() } # Handle Cancel from Freeze Window observeEvent(input$cancel_loading, { reset_fetch("Cancelled by user") showNotification("Loading cancelled by user.", type = "warning") }) # Stage 1: Initialization and Fetch observe({ req(window_debounced()$start, window_debounced()$end) current_request <- window_debounced() # If no station selected, ensure UI is unfrozen if (is.null(current_request$id)) { unfreeze_ui_if_idle() return() } last_request <- fetch_last_processed_request() # Skip if we've already processed this exact request if (!is.null(last_request) && identical(current_request, last_request)) { return() } # Mark processed fetch_last_processed_request(current_request) # Basic setup station_id <- isolate(window_debounced()$id) st_meta <- isolate(all_stations()) %>% filter(id == station_id) if (nrow(st_meta) == 0) { reset_fetch("Station not found.") return() } res_key <- tolower(isolate(current_resolution())) cache_key <- cache_key_for(station_id, res_key) cached_df <- get_cached_station(cache_key) if (!is.null(cached_df)) { station_data(cached_df) loading_diagnostics(paste0("Loaded ", nrow(cached_df), " records from cache.")) updateNavbarPage(session, "main_nav", selected = "Dashboard") loading_status(FALSE) # Show plotting message and wait for plots to render session$sendCustomMessage("freezeUI", list(text = "Plotting data...", allowCancel = FALSE)) session$onFlushed(function() { session$sendCustomMessage("waitForPlots", list()) }, once = TRUE) return() } loading_status(TRUE) s_name <- st_meta$name[1] s_country_fetch <- st_meta$country[1] msg <- paste0("Fetching data for ", s_name, " (", s_country_fetch, ")...") loading_diagnostics(msg) fetch_message(msg) session$sendCustomMessage("freezeUI", list(text = msg)) # Use later to allow UI flush later::later(function() { tryCatch( { s_date <- current_request$start e_date <- current_request$end # Fetch Data df <- read_meteogate_data(station_id, s_date, e_date) if (is.null(df) || nrow(df) == 0) { loading_diagnostics("No data returned from API.") station_data(NULL) } else { set_cached_station(cache_key, df) station_data(df) loading_diagnostics(paste0("Loaded ", nrow(df), " records.")) # Navigate to Dashboard updateNavbarPage(session, "main_nav", selected = "Dashboard") } }, error = function(e) { loading_diagnostics(paste("Error:", e$message)) station_data(NULL) } ) loading_status(FALSE) # Show plotting message and wait for plots to render session$sendCustomMessage("freezeUI", list(text = "Plotting data...", allowCancel = FALSE)) session$onFlushed(function() { session$sendCustomMessage("waitForPlots", list()) }, once = TRUE) }, fetch_ui_flush_delay) }) # Placeholder observers for removed stages to prevent errors if logic still triggers them # But strictly speaking we just removed the logic setting them. # Output logic for Panel output$is_loading <- reactive({ loading_status() }) outputOptions(output, "is_loading", suspendWhenHidden = FALSE) output$station_ready <- reactive({ !is.null(station_data()) }) outputOptions(output, "station_ready", suspendWhenHidden = FALSE) output$data_diagnostics <- renderUI({ HTML(loading_diagnostics()) }) output$has_diag <- reactive({ nzchar(loading_diagnostics()) }) outputOptions(output, "has_diag", suspendWhenHidden = FALSE) # Station Meta output$panel_station_name <- renderText({ req(current_station_id()) s <- all_stations() %>% filter(id == current_station_id()) if (nrow(s) > 0) paste0(s$name[1], " (", s$id[1], ")") else current_station_id() }) output$panel_station_meta <- renderText({ req(current_station_id()) s <- all_stations() %>% filter(id == current_station_id()) if (nrow(s) > 0) { paste0( s$state[1], " | ", "Lat: ", s$latitude[1], " | Lon: ", s$longitude[1], " | Elev: ", s$elevation[1], "m" ) } else { "" } }) # Plots observe({ df <- station_data() req(df) # Filter by Date Range from Modal (local processing) if (!is.null(input$modal_date_start) && !is.null(input$modal_date_end)) { df <- df %>% filter( datetime >= as.POSIXct(input$modal_date_start), datetime <= as.POSIXct(input$modal_date_end) + days(1) ) } # Standalone plots for grid layout output$temp_single_plot <- renderPlotly({ create_temperature_single_plot(df) }) output$humidity_plot <- renderPlotly({ create_humidity_single_plot(df) }) # Individual solar/precip plots output$solar_radiation_plot <- renderPlotly({ create_solar_radiation_plot(df) }) output$sunshine_duration_plot <- renderPlotly({ create_sunshine_plot(df) }) output$etp_plot <- renderPlotly({ create_etp_plot(df) }) output$precipitation_only_plot <- renderPlotly({ create_precipitation_only_plot(df, resolution = tolower(current_resolution())) }) output$snow_plot <- renderPlotly({ create_snow_plot(df) }) output$ground_temp_plot <- renderPlotly({ create_ground_temp_plot(df) }) output$soil_temp_plot <- renderPlotly({ create_soil_temp_plot(df) }) output$wind_2m_gust_plot <- renderPlotly({ create_wind_2m_gust_plot(df) }) # New plots (Pressure, Wind Time Series, Cloud, Visibility) output$pressure_plot <- renderPlotly({ create_pressure_plot(df) }) output$wind_time_series <- renderPlotly({ create_wind_time_series_plot(df) }) output$cloud_cover_plot <- renderPlotly({ create_cloud_cover_plot(df) }) output$visibility_plot <- renderPlotly({ create_visibility_plot(df) }) # Legacy render calls removed (temp_plot, precip_plot, solar_plot) output$wind_rose <- renderPlotly({ create_wind_rose_plot(df) }) output$weathergami_plot <- renderPlotly({ create_weathergami_plot(df) }) }) # Tables output$hourly_data_table <- DT::renderDataTable({ req(station_data()) res <- tolower(current_resolution()) df_display <- station_data() df_display <- format_wind_speed_decimals(df_display, digits = 1) display_cols <- get_station_display_cols(df_display) units <- attr(df_display, "units") # Always format datetime with hours and minutes for accuracy df_display <- df_display %>% mutate(datetime = format(datetime, "%Y-%m-%d %H:%M")) # Ensure datetime is at the beginning if it exists if ("datetime" %in% display_cols) { display_cols <- c("datetime", setdiff(display_cols, "datetime")) } # Create readable labels for all columns (including units if available) pretty_labels <- sapply(seq_along(display_cols), function(i) { col <- display_cols[i] # Convert name to title case label <- col %>% gsub("_", " ", .) %>% stringr::str_to_title() # Special case for Datetime if (col == "datetime") { return(if (res == "daily") "Date" else "Date/Time") } # Append unit in square brackets if available for this parameter if (!is.null(units) && !is.null(units[[col]])) { label <- paste0(label, " [", units[[col]], "]") } return(label) }, USE.NAMES = FALSE) # Render the table with all available data datatable(df_display %>% select(all_of(display_cols)), colnames = pretty_labels, options = list(pageLength = 15, scrollX = TRUE) ) }) # Parameter Definitions UI - shows only available parameters for this station output$param_definitions_ui <- renderUI({ req(current_resolution(), station_data(), current_station_id()) res <- tolower(current_resolution()) df <- station_data() sid <- current_station_id() # Get Metadata for this station # Get Metadata for this station st_meta <- all_stations() %>% filter(id == sid) expected_str <- NA_character_ if (res == "hourly" && "params_hourly" %in% names(st_meta)) { expected_str <- st_meta$params_hourly[1] } else if (res == "daily" && "params_daily" %in% names(st_meta)) { expected_str <- st_meta$params_daily[1] } else if (res == "10-min" && "params_10min" %in% names(st_meta)) { expected_str <- st_meta$params_10min[1] } else if (res == "monthly" && "params_monthly" %in% names(st_meta)) expected_str <- st_meta$params_monthly[1] # Identify available columns (same logic as the table) display_cols <- get_station_display_cols(df) # Determine which columns have non-NA data has_data <- sapply(display_cols, function(col) { any(!is.na(df[[col]])) }) available_cols <- display_cols[has_data] available_labels <- available_cols %>% gsub("_", " ", .) %>% stringr::str_to_title() # Clean up Datetime if (length(available_labels) > 0 && available_labels[1] == "Datetime") { available_labels[1] <- if (res == "daily") "Date" else "Date/Time" } ui_elements <- list() if (length(available_labels) > 0) { ui_elements[[length(ui_elements) + 1]] <- div( style = "background-color: #d4edda; border: 1px solid #28a745; border-radius: 5px; padding: 10px; margin-bottom: 5px; font-size: 0.85rem;", tags$strong(bsicons::bs_icon("check-circle"), " Available: "), paste(available_labels, collapse = " | ") ) } if (!is.null(expected_str) && !is.na(expected_str) && nzchar(expected_str)) { ui_elements[[length(ui_elements) + 1]] <- div( style = "background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px; padding: 10px; margin-bottom: 5px; font-size: 0.85rem;", tags$strong(bsicons::bs_icon("info-circle"), " Metadata Expected Parameters: "), expected_str ) } if (length(ui_elements) == 0) { return(div( style = "background-color: #f8d7da; border: 1px solid #dc3545; border-radius: 5px; padding: 10px; margin-bottom: 15px; font-size: 0.85rem;", bsicons::bs_icon("x-circle"), " No data found for this station and resolution." )) } tagList(ui_elements) }) # Download Handler output$download_hourly <- downloadHandler( filename = function() { id <- current_station_id() res <- if (!is.null(current_resolution())) tolower(current_resolution()) else "data" if (is.null(id)) { paste0("meteo_data_", res, ".xlsx") } else { paste0("meteo_station_", id, "_", res, ".xlsx") } }, content = function(file) { req(station_data()) # writexl handles dates nicely natively. out_df <- station_data() %>% format_wind_speed_decimals(digits = 1) write_xlsx(out_df, path = file) } ) # Info Callout Card output$station_info_header <- renderUI({ id <- current_station_id() if (is.null(id)) { return(NULL) } # Fetch basic meta directly from the base list to avoid blocking on index loading # This ensures the header stays visible during resolution switch stations_base <- get_meteogate_stations() meta <- stations_base %>% dplyr::filter(id == !!id) if (nrow(meta) == 0) { # Try from enriched list just in case meta <- all_stations() %>% dplyr::filter(id == !!id) if (nrow(meta) == 0) { return(NULL) } } s_name <- meta$name[1] s_state <- meta$country[1] s_elev <- meta$elevation[1] # Dynamic Badge Logic res_label <- current_resolution() res_class <- if (tolower(res_label) == "hourly") "bg-primary" else "bg-success" # Data Range df <- station_data() 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]) } # 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(paste0(s_name, " (", s_state, ")"), style = "font-size: 1.1rem;"), br(), tags$small(class = "text-muted", paste("ID:", id)) ), # Col 2: Location div( strong("Location"), br(), span(s_state), br(), tags$small(class = "text-muted", paste0(meta$latitude[1], "°N, ", meta$longitude[1], "°E")) ), # Col 3: Elevation & Resolution div( strong("Technical"), br(), span(paste0(s_elev, " m")), br(), span(class = paste("badge", res_class), res_label) ), # Col 4: Period div( strong("Data Selection"), br(), span(dates_text) ), # Col 5: Actions div( class = "d-flex align-items-center justify-content-end", downloadButton( "download_hourly", label = "Export Excel", class = "btn-sm btn-primary", icon = icon("file-excel") ) ) ) ) ) }) output$table <- DT::renderDataTable({ # React to changes in parameter and time (via viewport_obs_data) obs_data <- viewport_obs_data() current_param <- input$map_parameter if (is.null(current_param)) current_param <- "air_temperature" # Determine column label based on parameter param_label <- switch(current_param, "air_temperature" = "Temperature (°C)", "precipitation_amount" = "Precipitation (mm)", "air_pressure_at_mean_sea_level" = "Pressure (hPa)", "wind_speed" = "Wind Speed (m/s)", "Value" ) # Base metadata base_stations <- filtered_stations() %>% mutate( display_params = sapply(available_params, function(p) get_resolution_params(tolower(current_resolution()), p)) ) # Keep only stations with data for selected parameter + selected time step if (!is.null(obs_data) && nrow(obs_data) > 0) { obs_filtered <- obs_data %>% filter(!is.na(station_id), !is.na(temp)) %>% group_by(station_id) %>% arrange(desc(datetime)) %>% slice(1) %>% ungroup() %>% select(station_id, temp) } else { obs_filtered <- data.frame( station_id = character(0), temp = numeric(0), stringsAsFactors = FALSE ) } # obs_data has columns: station_id, temp (value), datetime, param_name # We join on id = station_id and keep only matched stations df_stations <- base_stations %>% inner_join(obs_filtered, by = c("id" = "station_id")) %>% rename(!!param_label := temp) # Select columns for display # We put the value column after Elevation df_stations <- df_stations %>% select( "Station ID" = id, "Name" = name, "Country" = country, "Region" = region, "Elevation" = elevation, !!param_label, "Parameters" = display_params ) datatable(df_stations, selection = "none", rownames = FALSE, options = list(pageLength = 20), callback = JS(" table.on('dblclick', 'tr', function() { var rowData = table.row(this).data(); if (rowData !== undefined && rowData !== null) { // First column is Station ID (id_clim) var stationId = rowData[0]; Shiny.setInputValue('table_station_dblclick', stationId, {priority: 'event'}); } }); ") ) }) # Selection from Table - Double Click (uses station ID directly) observeEvent(input$table_station_dblclick, { raw_id <- as.character(input$table_station_dblclick) req(raw_id) station_id <- trimws(raw_id) # Immediate tab switch for responsiveness updateNavbarPage(session, "main_nav", selected = "Dashboard") # Set ID and trigger fetch immediately current_station_id(station_id) # Force fetch state machine to re-evaluate fetch_trigger(fetch_trigger() + 1) # Sync dropdown (sidebar) updateSelectizeInput(session, "station_selector", selected = station_id) # Try to find metadata for map highlight s <- NULL df <- filtered_stations() if (!is.null(df)) { s <- df %>% filter(as.character(id) == station_id) } if (is.null(s) || nrow(s) == 0) { # Try all stations s <- all_stations() %>% filter(as.character(id) == station_id) } # Only proceed with map interactions if we found metadata if (!is.null(s) && nrow(s) > 0) { lat_val <- s$latitude[1] lng_val <- s$longitude[1] # Generate Label for marker res_info <- if ("resolution_info" %in% names(s)) s$resolution_info[1] else "Data status unavailable" if (is.null(res_info) || is.na(res_info)) res_info <- "Data status unavailable for current filter" # Try to get temperature from view session for the label obs <- viewport_obs_data() match <- if (!is.null(obs)) obs %>% filter(station_id == station_id) else NULL current_temp <- if (!is.null(match) && nrow(match) > 0) match$temp[1] else NULL current_param_name <- if (!is.null(match) && nrow(match) > 0 && "param_name" %in% names(match)) { match$param_name[1] } else { NA_character_ } current_param <- isolate(input$map_parameter) if (is.null(current_param)) current_param <- "air_temperature" param_label <- if (current_param == "precipitation_amount") "Precipitation" else "Air Temperature" param_unit <- if (current_param == "precipitation_amount") "mm" else "°C" missing_label <- if (current_param == "precipitation_amount") "No precip available at this hour" else NULL label_name <- if (current_param == "precipitation_amount") { format_precip_label(current_param_name, param_label) } else { param_label } lbl <- generate_station_label( s$name[1], s$id[1], s$country[1], s$start_date[1], s$end_date[1], s$detailed_summary[1], res_info, val = current_temp, label_name = label_name, unit = param_unit, missing_label = missing_label ) highlight_selected_station(maplibre_proxy("map"), lng_val, lat_val, lbl) } }) # Dynamic Plots Panel - All plots in grid format (no tabs) # Only display plots that have data available output$details_tabs <- renderUI({ req(current_resolution()) res <- tolower(current_resolution()) df <- station_data() req(df) # Clean data first to ensure flags (e.g. 9999) are treated as NA for availability checks df <- clean_weather_data(df) # Check data availability for each parameter has_temp <- ("temp" %in% names(df) && any(!is.na(df$temp))) || ("temp_min" %in% names(df) && any(!is.na(df$temp_min))) || ("temp_max" %in% names(df) && any(!is.na(df$temp_max))) has_rh <- ("rh" %in% names(df) && any(!is.na(df$rh))) # For humidity plot, need either RH or ability to calculate dew point has_humidity_data <- has_rh || (has_temp && has_rh) 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_pressure <- ("pressure" %in% names(df) && any(!is.na(df$pressure))) has_cloud <- ("cloud_cover" %in% names(df) && any(!is.na(df$cloud_cover))) has_precip <- ("precip" %in% names(df) && any(!is.na(df$precip))) has_snow <- ("snow_fresh" %in% names(df) && any(df$snow_fresh > 0, na.rm = TRUE)) || ("snow_depth" %in% names(df) && any(df$snow_depth > 0, na.rm = TRUE)) has_ground_temp <- ("temp_min_ground" %in% names(df) && any(!is.na(df$temp_min_ground))) || ("temp_min_50cm" %in% names(df) && any(!is.na(df$temp_min_50cm))) has_soil_temp <- ("soil_temp_10cm" %in% names(df) && any(!is.na(df$soil_temp_10cm))) || ("soil_temp_20cm" %in% names(df) && any(!is.na(df$soil_temp_20cm))) || ("soil_temp_50cm" %in% names(df) && any(!is.na(df$soil_temp_50cm))) has_wind_2m_gust <- ("wind_speed_2m" %in% names(df) && any(!is.na(df$wind_speed_2m))) || ("wind_gust_inst" %in% names(df) && any(!is.na(df$wind_gust_inst))) has_solar <- ("solar_global" %in% names(df) && any(!is.na(df$solar_global))) has_sunshine <- ("sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration))) has_etp <- ("etp" %in% names(df) && any(!is.na(df$etp))) has_wind_dir <- ("wind_dir" %in% names(df) && any(!is.na(df$wind_dir))) && has_wind has_pressure_data <- ("pressure" %in% names(df) && any(!is.na(df$pressure))) || ("pressure_sea" %in% names(df) && any(!is.na(df$pressure_sea))) || ("pressure_station" %in% names(df) && any(!is.na(df$pressure_station))) # Check generic pressure for fallback has_vis <- ("visibility" %in% names(df) && any(!is.na(df$visibility))) has_daily_temp <- ("temp_min" %in% names(df) && any(!is.na(df$temp_min))) && ("temp_max" %in% names(df) && any(!is.na(df$temp_max))) # Build list of available plots plot_list <- tagList() # Temperature plot (standalone with Tmin/Tmax for daily) if (has_temp) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("temp_single_plot", height = "320px"))) } # Humidity plot (separate plot for RH and dew point) if (has_humidity_data) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("humidity_plot", height = "320px"))) } # Individual precipitation plot (no snow) if (has_precip) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("precipitation_only_plot", height = "320px"))) } if (has_snow) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("snow_plot", height = "320px"))) } # Ground Temperature Plot (New) if (has_ground_temp) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("ground_temp_plot", height = "320px"))) } # Soil Temperature Plot (New) if (has_soil_temp) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("soil_temp_plot", height = "320px"))) } # Pressure Plot (New) if (has_pressure_data) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("pressure_plot", height = "320px"))) } # Cloud Cover Plot (New) if (has_cloud) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("cloud_cover_plot", height = "320px"))) } # Wind Time Series Plot (New) - Speed & Gust if (has_wind) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("wind_time_series", height = "320px"))) } # Wind 2m & Instant Gust (New) if (has_wind_2m_gust) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("wind_2m_gust_plot", height = "320px"))) } # Visibility Plot (New) if (has_vis) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("visibility_plot", height = "320px"))) } # Solar radiation (separate) if (has_solar) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("solar_radiation_plot", height = "320px"))) } # Sunshine duration (separate) if (has_sunshine) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("sunshine_duration_plot", height = "320px"))) } # ETP (separate) if (has_etp) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("etp_plot", height = "320px"))) } # Conditional: Wind Rose for hourly, Weathergami for daily if (res == "hourly" && has_wind_dir) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("wind_rose", height = "320px"))) } if (res == "daily" && has_daily_temp) { plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("weathergami_plot", height = "320px"))) } # Wrap in grid container div( class = "row g-3", style = "padding: 10px;", plot_list ) }) # Map Legend (Dynamic) output$map_legend <- renderUI({ obs <- viewport_obs_data() param_type <- input$map_parameter if (is.null(param_type) || param_type == "air_temperature") { # Temperature Legend - ECMWF palette (sh_all_fM50t58i2) # Use fixed 2°C intervals from -50°C to 58°C ecmwf_breaks <- seq(-50, 58, by = 2) # Determine displayed range based on data t_max <- 40 t_min <- -20 if (!is.null(obs) && nrow(obs) > 0) { current_range <- range(obs$temp, na.rm = TRUE) # Round to nearest ECMWF interval t_max <- ceiling(current_range[2] / 2) * 2 t_min <- floor(current_range[1] / 2) * 2 t_max <- min(58, t_max) t_min <- max(-50, t_min) } # Filter ECMWF breaks to displayed range temps <- ecmwf_breaks[ecmwf_breaks >= t_min & ecmwf_breaks <= t_max] temps <- rev(temps) tagList( div(class = "legend-title", "[°C]"), lapply(temps, function(t) { color <- temp_to_color(t) # Show label for key values show_label <- t %% 8 == 0 | t == 0 div( class = "legend-item", div(class = "legend-color", style = paste0("background-color: ", color, "; opacity: 0.8;")), if (show_label) div(class = "legend-label", t) ) }) ) } else if (param_type == "air_pressure_at_mean_sea_level") { # Pressure Legend - ECMWF Style (5hPa steps) p_stops <- seq(960, 1040, by = 5) # Determine range to display based on data (snap to 5hPa) display_min <- 960 display_max <- 1040 if (!is.null(obs) && nrow(obs) > 0) { # Calculate data range rng <- range(obs$temp, na.rm = TRUE) # Snap to nearest 5 d_min_snap <- floor(rng[1] / 5) * 5 d_max_snap <- ceiling(rng[2] / 5) * 5 # Constrain to palette limits display_min <- max(960, d_min_snap) display_max <- min(1040, d_max_snap) # Ensure valid range if (display_max < display_min) display_max <- display_min } # Filter stops for display display_stops <- p_stops[p_stops >= display_min & p_stops <= display_max] # Reverse for vertical legend (High at top usually, but here we list Top->Bottom) # Standard legend usually lists high values at top? Or low values? # Existing code for Temp does High->Low (Warm->Cold). # For Pressure, let's do High->Low (Red->Purple) display_stops <- rev(display_stops) tagList( div(class = "legend-title", "[hPa]"), lapply(display_stops, function(p) { # Legend item represents the bin starting at p? # Since our palette is discrete intervals, let's show the color for the value p color <- pressure_to_color(p) # Show label for every step (since 5hPa is coarse enough) div( class = "legend-item", div(class = "legend-color", style = paste0("background-color: ", color, "; opacity: 0.9;")), div(class = "legend-label", p) ) }) ) } else if (param_type == "wind_speed") { # Wind Speed Legend - ECMWF Style (Discrete steps) # Define standard steps w_stops <- c(0.5, 2, 4, 6, 10, 15, 20, 25, 30, 40, 50) # Determine max to display display_max <- 30 if (!is.null(obs) && nrow(obs) > 0) { rng <- range(obs$temp, na.rm = TRUE) w_max_data <- rng[2] # Find appropriate max stop display_max <- w_stops[findInterval(w_max_data, w_stops) + 1] if (is.na(display_max)) display_max <- 50 display_max <- max(15, display_max) # Show at least up to 15m/s } # Filter stops display_stops <- w_stops[w_stops <= display_max] # High -> Low display_stops <- rev(display_stops) tagList( div(class = "legend-title", "[m/s]"), lapply(display_stops, function(w) { # For legend, show color corresponding to the bin *ending* at w? # Or starting? # Palette logic: findInterval(val, stops) # So value 12 is >10 and <15. # We want to show the color for the bin represented by 'w'. # Let's pick a value slightly below w for the color look up if looking downwards? # Actually, our stops are upper bounds of the lower bins? # stops <- c(0.5, 2, 4...) # Val 1.5 -> idx 2 (between 0.5 and 2) # Simpler: just pass 'w' to wind_speed_to_color. # If w=10, it returns color for 10-15 bin? No. # wind_speed_to_color(10) -> idx 5 (stops=...6, 10...) # 10 is the upper bound of 6-10? findInterval(10, ...6, 10) is index corresponding to 6. # Let's rely on wind_speed_to_color(w) returning the color for the "w" level. color <- wind_speed_to_color(w) div( class = "legend-item", div(class = "legend-color", style = paste0("background-color: ", color, "; opacity: 0.9;")), div(class = "legend-label", w) ) }) ) } else { # Precipitation Legend p_max <- 50 p_min <- 0 if (!is.null(obs) && nrow(obs) > 0) { current_range <- range(obs$temp, na.rm = TRUE) p_max <- ceiling(current_range[2] / 5) * 5 p_min <- 0 p_max <- min(50, max(5, p_max)) } precip_stops <- c(0, 0.1, 1, 5, 10, 25, 50) precip_stops <- precip_stops[precip_stops <= p_max] precip_stops <- rev(precip_stops) tagList( div(class = "legend-title", "[mm]"), lapply(precip_stops, function(p) { color <- precip_to_color(p) div( class = "legend-item", div(class = "legend-color", style = paste0("background-color: ", color, "; opacity: 0.8;")), div(class = "legend-label", p) ) }) ) } }) # Downloads output$download_data <- downloadHandler( filename = function() { paste0("meteo_station_", current_station_id(), ".csv") }, content = function(file) { out_df <- station_data() %>% format_wind_speed_decimals(digits = 1) write_csv(out_df, file) } ) }