# Define the server logic function(input, output, session) { # Show loading spinner on startup until stations are drawn session$sendCustomMessage("freezeUI", list(text = "Loading stations...")) # Reactive expression to filter the Parquet data by year and month filtered_parquet_data <- reactive({ month_number <- match(input$month, month.name) # Determine which dataset to use based on input parameter dataset <- if (input$parameter %in% c("Temperature", "Air Temperature")) tavg_dataset else prec_dataset # Filter the dataset using arrow's dplyr interface dataset %>% filter( VALUE >= -90, YEAR >= input$year_range[1], YEAR <= input$year_range[2], MONTH == month_number ) %>% group_by(ID) %>% # For precipitation, we might want sum instead of mean if it was daily, but these are likely monthly means/totals? # Assuming monthly values are already means for temp and totals for precip. # If we aggregate over years (multiannual mean), we want mean of the monthly values. summarize(mean_value = mean(VALUE, na.rm = TRUE)) %>% collect() # Collect only the filtered and summarized data }) # Reactive expression to filter the stations based on year range and Parquet data filtered_stations <- reactive({ filtered_data <- filtered_parquet_data() # Determine which station metadata to use stations_info <- if (input$parameter %in% c("Temperature", "Air Temperature")) stations_data else prec_stations_data # Join filtered data with station data stations_info %>% filter( first_year <= input$year_range[1], last_year >= input$year_range[2], ID %in% filtered_data$ID ) %>% left_join(filtered_data, by = "ID") }) output$map_title <- renderText({ paste("Multiannual mean:", input$year_range[1], "to", input$year_range[2]) }) # Set the initial view for the map initial_lng <- 5 # mean(stations_data$LONGITUDE, na.rm = TRUE) initial_lat <- mean(stations_data$LATITUDE, na.rm = TRUE) initial_zoom <- 2 # Reactive values to store the selected and previous station IDs selected_station_id <- reactiveVal(NULL) previous_station_id <- reactiveVal(NULL) # Reactive values for map state style_change_trigger <- reactiveVal(0) # Triggers redraw after style change stations_before_id <- reactiveVal(NULL) # Layer ID to insert stations before current_raster_layers <- reactiveVal(character(0)) # Track raster layers stations_loaded <- reactiveVal(FALSE) # Track if stations have been drawn basemap_debounced <- shiny::debounce(reactive(input$basemap), 200) # Render the MapLibre map # Render the MapLibre map output$station_map <- renderMaplibre({ print("Initializing MapLibre...") 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") }) # Initialize map bounds / Home Zoom observeEvent(input$home_zoom, { maplibre_proxy("station_map") %>% fly_to(center = c(initial_lng, initial_lat), zoom = initial_zoom) }) # --- 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("station_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() { shiny::withReactiveDomain(current_session, { current_basemap <- isolate(input$basemap) if (current_basemap != selected_basemap) { return() } apply_label_visibility(maplibre_proxy("station_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() { 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("station_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("station_map") %>% set_layout_property(layer_id_kill, "visibility", "none") }, error = function(e) {} ) } apply_label_visibility(maplibre_proxy("station_map"), isolate(input$show_labels)) stations_before_id("waterway_line_label") style_change_trigger(isolate(style_change_trigger()) + 1) }) }, delay = 0.5) } }) # Toggle Labels observeEvent(input$show_labels, { req(input$basemap %in% c("ofm_positron", "ofm_bright", "sentinel")) apply_label_visibility(maplibre_proxy("station_map"), input$show_labels) }) # Initial rendering of all markers # Initial rendering of all markers # Render Stations Layer (MapLibre) # Flag to track if map is initialized map_initialized <- reactiveVal(FALSE) # One-time observer to detect map load # Delay slightly to ensure MapLibre style is fully loaded before adding layers observe({ req(input$station_map_zoom) if (!map_initialized()) { later::later(function() { map_initialized(TRUE) }, delay = 0.5) } }) # Render Stations Layer (MapLibre) # Render Stations Layer (MapLibre) - DATA UPDATE ONLY observe({ req(map_initialized()) # Wait for map to be ready req(filtered_stations()) style_change_trigger() # Re-add layer if style changes # Show loading spinner while redrawing stations session$sendCustomMessage("freezeUI", list(text = "Loading stations...")) data <- filtered_stations() param <- input$parameter # Note: We do NOT depend on selected_station_id() here to avoid full redraws on click # Define palettes and units if (param %in% c("Temperature", "Air Temperature")) { palette_domain <- data$mean_value bins <- c(-Inf, -40, -30, -20, -15, -12.5, -10, -7.5, -5, -2.5, 0, 2.5, 5, 7.5, 10, 12.5, 15, 17.5, 20, 22.5, 25, 27.5, 30, 35, 40, Inf) blues <- colorRampPalette(c("#053061", "#4393c3", "#d1e5f0"))(10) reds <- colorRampPalette(c("#fff7bc", "#fdae61", "#d73027", "#67001f"))(15) palette_colors <- c(blues, reds) units <- "°C" prefix <- "Mean Temp:" } else { palette_domain <- data$mean_value palette_colors <- colorRampPalette(RColorBrewer::brewer.pal(9, "YlGnBu"))(12) units <- "mm" prefix <- "Mean Precip:" bins <- c(0, 10, 25, 50, 75, 100, 150, 200, 300, 500, 1000, 2000, Inf) } # Color Function pal_fun <- colorBin(palette_colors, domain = palette_domain, bins = bins, na.color = "transparent") # Prepare Data for MapLibre # Vectorized popup HTML (replaces per-row mapply+generateLabel) yr <- input$year_range popup_html <- paste0( "Station: ", data$NAME, "
", "Country: ", data$Country, "
", "ID: ", data$ID, "
", "Elevation: ", data$STNELEV, " m
", "Available years: ", data$first_year, " - ", data$last_year, "
", "Selected years: ", yr[1], " - ", yr[2], "
", prefix, " ", round(data$mean_value, 1), " ", units, "
Click to get graph and data" ) map_data <- data %>% mutate( circle_color = pal_fun(mean_value), popup_content = popup_html ) %>% st_as_sf(coords = c("LONGITUDE", "LATITUDE"), crs = 4326) # Add Layer with base styles maplibre_proxy("station_map") %>% clear_layer("stations") %>% add_circle_layer( id = "stations", source = map_data, circle_color = get_column("circle_color"), circle_radius = 5, circle_stroke_color = get_column("circle_color"), circle_stroke_width = 2, circle_opacity = 0.7, circle_stroke_opacity = 1, tooltip = get_column("popup_content"), before_id = stations_before_id() ) # Re-apply current selection style immediately after rendering cur_sel <- isolate(selected_station_id()) update_selection_style(cur_sel) # Dismiss the loading spinner after stations are drawn session$sendCustomMessage("unfreezeUI", list()) }) # Helper to update selection styles efficiently update_selection_style <- function(id) { if (is.null(id)) { # Reset to base styles maplibre_proxy("station_map") %>% set_paint_property("stations", "circle-radius", 5) %>% set_paint_property("stations", "circle-stroke-width", 2) %>% # Reset stroke color to match circle color using data-driven property set_paint_property("stations", "circle-stroke-color", list("get", "circle_color")) } else { # Apply highlight style using expressions # Radius: 8 if selected, 5 otherwise radius_expr <- list("case", list("==", list("get", "ID"), id), 8, 5) # Stroke Width: 3 if selected, 2 otherwise width_expr <- list("case", list("==", list("get", "ID"), id), 3, 2) # Stroke Color: Red if selected, else use circle_color color_expr <- list("case", list("==", list("get", "ID"), id), "#FF0000", list("get", "circle_color")) maplibre_proxy("station_map") %>% set_paint_property("stations", "circle-radius", radius_expr) %>% set_paint_property("stations", "circle-stroke-width", width_expr) %>% set_paint_property("stations", "circle-stroke-color", color_expr) } } # Selection Observer - VISUAL UPDATE ONLY observeEvent(selected_station_id(), { req(map_initialized()) update_selection_style(selected_station_id()) }, ignoreInit = TRUE ) # Reactive expression to retrieve time series data for the selected station and year/month inputs # Reactive expression to retrieve time series data for the selected station and year/month inputs time_series_data <- reactive({ req(selected_station_id()) # Ensure a station is clicked station_id <- selected_station_id() month <- input$month dataset <- if (input$parameter == "Temperature") tavg_dataset else prec_dataset # Filter the dataset based on selected station, month, and year range time_series_data <- dataset %>% filter( VALUE >= -90, YEAR >= input$year_range[1], YEAR <= input$year_range[2], MONTH == match(input$month, month.name), ID == station_id ) %>% collect() |> mutate(YEAR = as.numeric(YEAR)) time_series_data }) # Observer to handle rendering of the time series plot when inputs (month, year) or station selection change output$time_series_plot <- renderPlotly({ data <- time_series_data() # Get the filtered time series data req(nrow(data) > 0) # Ensure there is data to plot station_id <- selected_station_id() month <- input$month param <- input$parameter y_label <- if (param == "Temperature") "Temperature (°C)" else "Precipitation (mm)" title <- if (param == "Temperature") "Daily Mean Temperature" else "Total Precipitation" render_time_series_plot(data, station_id, month, y_label, title) }) # Observe to handle click events on the map markers and update plot accordingly # Observe to handle click events on the map markers and update plot accordingly observeEvent(input$station_map_feature_click, { clicked <- input$station_map_feature_click # Check layer ID (support both layer_id and layer) is_station_layer <- FALSE if (!is.null(clicked$layer_id) && clicked$layer_id == "stations") is_station_layer <- TRUE if (!is.null(clicked$layer) && clicked$layer == "stations") is_station_layer <- TRUE if (!is.null(clicked) && is_station_layer) { # Handle potential case sensitivity or property name differences props <- clicked$properties # Try 'ID' (original) then 'id' (potentially lowercased by JS/sf) click_id <- if (!is.null(props$ID)) props$ID else props$id print(paste("Selected ID:", click_id)) if (!is.null(click_id)) { selected_station_id(click_id) # Center map on clicked station, preserving current zoom if (!is.null(clicked$lng) && !is.null(clicked$lat)) { maplibre_proxy("station_map") %>% fly_to(center = c(clicked$lng, clicked$lat), zoom = input$station_map_zoom) } } } }) # --- URL Parameter Parsing --- url_initialized <- reactiveVal(FALSE) parse_url_params <- function(query) { params <- list() if (length(query) == 0 || query == "") { return(params) } # Parse query string q_str <- sub("^\\?", "", query) pairs <- strsplit(q_str, "&")[[1]] for (pair in pairs) { parts <- strsplit(pair, "=")[[1]] if (length(parts) == 2) { key <- parts[1] val <- URLdecode(parts[2]) params[[key]] <- val } } params } # 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) { # 1. Parameter if (!is.null(params$parameter)) { updateSelectInput(session, "parameter", selected = params$parameter) } # 2. Month if (!is.null(params$month)) { updateSelectInput(session, "month", selected = params$month) } # 3. Year Range if (!is.null(params$start) && !is.null(params$end)) { updateSliderInput(session, "year_range", value = c(as.numeric(params$start), as.numeric(params$end))) } # 4. Station Selection if (!is.null(params$station)) { station_ref <- params$station shinyjs::delay(1000, { stations_info <- if (input$parameter %in% c("Temperature", "Air Temperature")) stations_data else prec_stations_data match <- stations_info %>% filter(ID == station_ref | NAME == station_ref) if (nrow(match) > 0) { selected_station_id(match$ID[1]) } }) } } url_initialized(TRUE) }) # Helper: Broadcast current state to parent page broadcast_state <- function() { sid <- selected_station_id() st_meta <- NULL if (!is.null(sid)) { stations_info <- if (input$parameter %in% c("Temperature", "Air Temperature")) stations_data else prec_stations_data st_meta <- stations_info %>% filter(ID == sid) %>% head(1) } station_id <- if (!is.null(st_meta) && nrow(st_meta) > 0) as.character(st_meta$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) else NULL session$sendCustomMessage("updateParentURL", list( station = station_id, stationName = station_name, country = country, parameter = input$parameter, month = input$month, yearStart = input$year_range[1], yearEnd = input$year_range[2] )) } # Observer to broadcast state on any relevant change observe({ # Depend on all state variables that should trigger a broadcast sid <- selected_station_id() param <- input$parameter month <- input$month year_range <- input$year_range req(url_initialized()) # Broadcast the current state st_meta <- NULL if (!is.null(sid)) { stations_info <- if (param %in% c("Temperature", "Air Temperature")) stations_data else prec_stations_data st_meta <- stations_info %>% filter(ID == sid) %>% head(1) } station_id <- if (!is.null(st_meta) && nrow(st_meta) > 0) as.character(st_meta$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) else NULL session$sendCustomMessage("updateParentURL", list( station = station_id, stationName = station_name, country = country, parameter = param, month = month, yearStart = year_range[1], yearEnd = year_range[2] )) }) # Reactive observer to update the plot when month or year inputs change observe({ req(input$month, input$year_range) # Ensure month and year range inputs are provided station_id <- selected_station_id() month <- input$month param <- input$parameter y_label <- if (param == "Temperature") "Temperature (°C)" else "Precipitation (mm)" title <- if (param == "Temperature") "Daily Mean Temperature" else "Total Precipitation" # Update the time series plot when month or year inputs change output$time_series_plot <- renderPlotly({ data <- time_series_data() # Get the filtered time series data req(nrow(data) > 0) # Ensure there is data to plot render_time_series_plot(data, station_id, month, y_label, title) }) }) # Create a reactive flag indicating whether the plot is available plot_available <- reactive({ req(selected_station_id()) nrow(time_series_data()) > 0 }) # Expose the plot_available flag to the client output$plot_available <- reactive({ plot_available() }) outputOptions(output, "plot_available", suspendWhenHidden = FALSE) # Inside your server.R or server function output$plot_panel <- renderUI({ if (plot_available()) { absolutePanel( draggable = F, bottom = 30, # Position from the bottom left = "50%", # Start from the middle of the screen right = "auto", width = "95%", height = "auto", style = "transform: translateX(-50%); max-width: 450px; background-color: rgba(255, 255, 255, 0.8); border-radius: 10px; padding: 10px;", # Transparent background with some styling # Time series plot plotlyOutput("time_series_plot", height = "200px"), # Download button positioned right below the plot, aligned to the left downloadButton( outputId = "download_data", label = NULL, # No text label icon = icon("download"), # Font Awesome download icon class = "custom-download-button", # Custom CSS class for styling style = "float: left; margin-top: -10px;" # Aligns the button to the left and positions it below the plot ) |> tooltip("Download data") # bslib data ) } }) # Render Map Legend output$map_legend <- renderUI({ param <- input$parameter # Define legend properties matching the map logic if (param %in% c("Temperature", "Air Temperature")) { bins <- c(-Inf, -40, -30, -20, -15, -12.5, -10, -7.5, -5, -2.5, 0, 2.5, 5, 7.5, 10, 12.5, 15, 17.5, 20, 22.5, 25, 27.5, 30, 35, 40, Inf) blues <- colorRampPalette(c("#053061", "#4393c3", "#d1e5f0"))(10) reds <- colorRampPalette(c("#fff7bc", "#fdae61", "#d73027", "#67001f"))(15) colors <- c(blues, reds) units <- "°C" # For temperature, we reverse the display so high values are at top display_bins <- rev(bins) display_colors <- rev(colors) } else { # Precip colors <- colorRampPalette(RColorBrewer::brewer.pal(9, "YlGnBu"))(12) units <- "mm" bins <- c(0, 10, 25, 50, 75, 100, 150, 200, 300, 500, 1000, 2000, Inf) display_bins <- rev(bins) display_colors <- rev(colors) } # Generate HTML items legend_items <- lapply(seq_along(display_colors), function(i) { # Bins are effectively boundaries. # With n colors, we have n+1 boundaries (if we include start/end), or we map n colors to n intervals. # The bins array has length N+1 for N colors. # display_bins has high values first. # display_colors has high value colors first. # Current interval: display_bins[i] (upper) to display_bins[i+1] (lower) # Wait, bins c(a, b, c) -> intervals (a,b), (b,c). 2 intervals, 3 boundaries. # length(colors) should be length(bins) - 1. # Let's verify lengths. # Temp: 10+15 = 25 colors. Bins: 26 values. Correct. # Prec: 12 colors. Bins: 13 values. Correct. val_high <- display_bins[i] val_low <- display_bins[i + 1] color <- display_colors[i] label_text <- if (is.infinite(val_high)) { paste0("> ", val_low) } else if (is.infinite(val_low)) { paste0("< ", val_high) } else { paste0(val_low, " – ", val_high) } tags$div( style = "display: flex; align-items: center; margin-bottom: 0px;", tags$span( style = sprintf("background: %s; width: 18px; height: 18px; margin-right: 8px; display: inline-block; opacity: 0.9; border: 1px solid #ccc; border-bottom: none;", color) ), tags$span( style = "font-size: 11px;", label_text ) ) }) absolutePanel( bottom = 30, left = 20, draggable = FALSE, width = 130, # Fixed width for neatness style = "background: white; padding: 10px; border-radius: 4px; box-shadow: 0 0 5px rgba(0,0,0,0.3); max-height: 80vh; overflow-y: auto;", tags$h6(style = "margin-top: 0; margin-bottom: 8px; font-weight: bold; text-align: center;", units), do.call(tags$div, legend_items) ) }) # Define the download handler for downloading the time series data output$download_data <- downloadHandler( filename = function() { # Create a dynamic filename based on station ID and month station_id <- selected_station_id() month <- input$month info <- if (input$parameter %in% c("Temperature", "Air Temperature")) tavg_meta else prec_meta paste0(info$NAME[info$ID == station_id], "_", station_id, "_", month, ".csv") }, content = function(file) { # Get the time series data for the clicked station data <- time_series_data() # Write the data to a CSV file write.csv(data, file, row.names = FALSE) } ) }