meteogate / server.R
alexdum's picture
Sync current state to Hugging Face
c9a261f
# 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(
"<div style='margin: 12px 0; padding: 10px; background: ", color, "10; border: 1px solid ", color, "30; border-radius: 8px; text-align: center; box-shadow: inset 0 1px 2px rgba(0,0,0,0.05);'>",
"<div style='font-size: 0.75em; color: #666; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; margin-bottom: 4px;'>", label_name, "</div>",
"<div style='font-size: 2em; font-weight: 900; color: ", color, "; line-height: 1; display: flex; align-items: baseline; justify-content: center;'>",
round(val, 1),
"<span style='font-size: 0.5em; margin-left: 3px; font-weight: 600;'>", unit, "</span>",
"</div>",
"</div>"
)
} else if (!is.null(missing_label) && !is.na(missing_label)) {
color <- missing_color
val_html <- paste0(
"<div style='margin: 12px 0; padding: 10px; background: ", color, "10; border: 1px solid ", color, "30; border-radius: 8px; text-align: center; box-shadow: inset 0 1px 2px rgba(0,0,0,0.05);'>",
"<div style='font-size: 0.75em; color: #666; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; margin-bottom: 4px;'>", label_name, "</div>",
"<div style='font-size: 1.4em; font-weight: 800; color: ", color, "; line-height: 1; display: flex; align-items: baseline; justify-content: center;'>",
htmltools::htmlEscape(missing_label),
"</div>",
"</div>"
)
}
paste0(
"<div style='font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif; min-width: 250px; max-width: 300px; padding: 4px;'>",
# Header Section
"<div style='border-bottom: 2px solid #f0f0f0; padding-bottom: 10px; margin-bottom: 10px;'>",
"<div style='font-size: 1.15em; font-weight: 800; color: #1a1a1a; line-height: 1.3; margin-bottom: 6px;'>", htmltools::htmlEscape(name), "</div>",
"<div style='display: flex; justify-content: space-between; align-items: center;'>",
"<span style='font-size: 0.8em; color: #0056b3; font-weight: 700; background: #eef4ff; padding: 3px 8px; border-radius: 20px; border: 1px solid #d0e1ff;'>", htmltools::htmlEscape(country), "</span>",
"<span style='font-size: 0.75em; color: #888; font-family: monospace; background: #f5f5f5; padding: 2px 5px; border-radius: 4px;'>", id, "</span>",
"</div>",
"</div>",
# Value Section (if available)
val_html,
# Parameters & Status Section
"<div style='background: #fafafa; border-radius: 8px; padding: 10px; border: 1px solid #f0f0f0;'>",
"<div style='font-size: 0.75em; color: #777; font-weight: 700; margin-bottom: 6px; text-transform: uppercase; display: flex; align-items: center; gap: 4px;'>",
"<span style='width: 8px; height: 8px; background: #28a745; border-radius: 50%; display: inline-block;'></span> Status & Capabilities",
"</div>",
"<div style='font-size: 0.85em; color: #333; line-height: 1.5;'>",
resolution_info,
"</div>",
"</div>",
"</div>",
"</div>"
)
},
error = function(e) {
paste0("<div style='padding:10px;'><b>", htmltools::htmlEscape(name), "</b><br><small color='#888'>(", id, ")</small></div>")
}
)
}
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"),
"<br>Available:<br>", 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(
"<div style='min-width: 180px; font-size: 13px;'>",
"<div style='font-weight: 700; font-size: 1.1em;'>", htmltools::htmlEscape(df$name), "</div>",
"<div style='font-size: 0.95em; color: #666;'>", htmltools::htmlEscape(df$country), "</div>",
"<div style='font-size: 0.95em; color: #666;'>",
htmltools::htmlEscape(label_name_vec), ": ", htmltools::htmlEscape(value_text),
"</div>",
"</div>"
)
}
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)
unfreeze_ui_if_idle()
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)
# Unfreeze
unfreeze_ui_if_idle()
}, 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)
}
)
}