# funs/plot_weather_dwd.R #' Create a placeholder plot for missing data #' @param message Message to display #' @return A plotly object create_empty_plot <- function(message = "Data not available for this parameter") { plotly::plot_ly() %>% plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), showlegend = FALSE) %>% plotly::add_annotations( text = message, showarrow = FALSE, xref = "paper", yref = "paper", x = 0.5, y = 0.5, font = list(size = 16, color = "#666") ) %>% plotly::layout( xaxis = list(visible = FALSE), yaxis = list(visible = FALSE), margin = list(t = 50, b = 50, l = 50, r = 50) ) %>% plotly::config(displaylogo = FALSE) } #' Ensure Temporal Continuity #' #' Fills missing time steps in the dataframe with explicit NA rows. #' Prevents Plotly from linking gaps across missing periods. #' #' @param df Data frame with a 'datetime' column #' @return Data frame with complete time sequence #' @export ensure_temporal_continuity <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) < 2) { return(df) } seq_by <- NULL if (!is.null(resolution)) { seq_by <- switch(tolower(resolution), "hourly" = "hour", "daily" = "day", "monthly" = "month", NULL ) } if (is.null(seq_by)) { # Detect resolution using minimum non-zero difference (base resolution) # This ensures that even if there are many gaps (raising median), the presence of *any* consecutive points proves the resolution. n_check <- min(nrow(df), 200) # Check more points to find at least one consecutive pair dt_vals <- as.numeric(difftime(df$datetime[2:n_check], df$datetime[1:(n_check - 1)], units = "hours")) # Filter out 0 (duplicates) and NAs dt_vals <- dt_vals[!is.na(dt_vals) & dt_vals > 0] if (length(dt_vals) == 0) { return(df) } dt <- min(dt_vals) seq_by <- "hour" if (dt >= 600) { seq_by <- "month" } else if (dt >= 23) { seq_by <- "day" } } # Generate complete sequence full_dates <- data.frame( datetime = seq( from = min(df$datetime), to = max(df$datetime), by = seq_by ) ) # Merge to fill gaps (right join to keep the sequence) # Using base merge to avoid extra dependency if possible, but full_join is safer if dplyr loaded df_complete <- dplyr::full_join(full_dates, df, by = "datetime") %>% dplyr::arrange(datetime) return(df_complete) } #' Split Dataframe into Continuous Chunks #' #' @param df Dataframe with datetime and value columns #' @param value_col Column name to check for NAs to define breaks #' @return List of dataframes split_into_chunks <- function(df, value_col) { # If no data, return empty list if (nrow(df) == 0) { return(list()) } # Identify runs of non-NA values # rle is perfect for this # We check if value IS NA. is_na_vec <- is.na(df[[value_col]]) # If all NA, return list if (all(is_na_vec)) { return(list()) } rle_res <- rle(is_na_vec) # Create a vector of Chunk IDs # Start ID at 1 # We want to increment ID for every change in status, effectively. # Actually, we just need to group "FALSE" (Not NA) blocks. # Expand rle to get group IDs for each row is silly, use cumsum change # Wait, simple split: # 1. Create a grouping index that changes every time we hit an NA block # Actually, simplest approach: # Find start and end indices of !is.na blocks # Let's stick to the rle expansion, it's robust chunk_ids <- rep(seq_along(rle_res$values), rle_res$lengths) df$chunk_id <- chunk_ids # Filter only the chunks where value is NOT NA valid_chunks <- df[!is_na_vec, ] if (nrow(valid_chunks) == 0) { return(list()) } # Split by chunk_id split(valid_chunks, valid_chunks$chunk_id) } #' Centralized Cleaning for DWD Data #' #' Ensures all weather columns are numeric and that DWD-specific #' flag values (8000, 9999, -999) are converted to NA. #' #' @param df Data frame to clean #' @return Cleaned data frame clean_dwd_data <- function(df) { if (is.null(df) || nrow(df) == 0) { return(df) } # Weather columns to check weather_cols <- c( "temp", "dew_point", "rh", "abs_humidity", "vapor_pressure", "wet_bulb_temp", "precip", "wind_speed", "wind_dir", "pressure", "station_pressure", "cloud_cover", "wind_gust_max", "solar_global", "sunshine_duration", "visibility", "soil_temp_2cm", "soil_temp_5cm", "soil_temp_10cm", "soil_temp_20cm", "soil_temp_50cm", "soil_temp_100cm", "snow_depth", "snow_water_equiv", "snow_fresh_sum", "snow_depth_sum", "temp_max", "temp_min", "temp_max_avg", "temp_min_avg" ) # Aggressive base R filtering for 8000 and other flags for (col in weather_cols) { if (col %in% names(df)) { # Convert to numeric if not already if (!is.numeric(df[[col]])) { df[[col]] <- suppressWarnings(as.numeric(as.character(df[[col]]))) } # Catch all variants of 8000, 9999, -999 bad_idx <- is.na(df[[col]]) | df[[col]] >= 7999 | df[[col]] <= -998 df[[col]][bad_idx] <- NA_real_ } } # Parameter-specific safety bounds (Physical sanity checks) if ("temp" %in% names(df)) df$temp[df$temp < -90 | df$temp > 60] <- NA_real_ if ("dew_point" %in% names(df)) df$dew_point[df$dew_point < -90 | df$dew_point > 60] <- NA_real_ if ("wet_bulb_temp" %in% names(df)) df$wet_bulb_temp[df$wet_bulb_temp < -90 | df$wet_bulb_temp > 60] <- NA_real_ if ("rh" %in% names(df)) df$rh[df$rh < 0 | df$rh > 100] <- NA_real_ if ("abs_humidity" %in% names(df)) df$abs_humidity[df$abs_humidity < 0] <- NA_real_ if ("vapor_pressure" %in% names(df)) df$vapor_pressure[df$vapor_pressure < 0] <- NA_real_ if ("precip" %in% names(df)) df$precip[df$precip < 0 | df$precip > 3000] <- NA_real_ if ("pressure" %in% names(df)) df$pressure[df$pressure < 800 | df$pressure > 1100] <- NA_real_ if ("station_pressure" %in% names(df)) df$station_pressure[df$station_pressure < 500 | df$station_pressure > 1100] <- NA_real_ if ("solar_global" %in% names(df)) df$solar_global[df$solar_global < 0 | df$solar_global > 3500] <- NA_real_ if ("visibility" %in% names(df)) df$visibility[df$visibility < 0] <- NA_real_ if ("cloud_cover" %in% names(df)) df$cloud_cover[df$cloud_cover < 0 | df$cloud_cover > 9] <- NA_real_ return(df) } #' Calculate Dew Point #' Approximation using Magnus formula calculate_dew_point <- function(temp, rh) { if (is.null(temp) | is.null(rh)) { return(NA) } # Constants a <- 17.625 b <- 243.04 alpha <- log(rh / 100) + (a * temp) / (b + temp) dp <- (b * alpha) / (a - alpha) return(dp) } #' Create Daily Summary Helper create_daily_summary <- function(df) { if (is.null(df) || nrow(df) == 0) { return(NULL) } # Centralized cleaning df <- clean_dwd_data(df) # Check if we already have daily min/max (from daily DWD data) has_daily_minmax <- "temp_min" %in% names(df) && "temp_max" %in% names(df) if (has_daily_minmax) { # Use existing daily min/max columns directly (DWD daily data: TNK, TXK) df %>% mutate(date = as.Date(datetime)) %>% group_by(date) %>% summarise( Tmax = if (all(is.na(temp_max))) NA else max(temp_max, na.rm = TRUE), Tmin = if (all(is.na(temp_min))) NA else min(temp_min, na.rm = TRUE), .groups = "drop" ) } else { # Calculate from hourly temp data (for hourly datasets) df %>% mutate(date = as.Date(datetime)) %>% group_by(date) %>% summarise( Tmax = if (all(is.na(temp))) NA else max(temp, na.rm = TRUE), Tmin = if (all(is.na(temp))) NA else min(temp, na.rm = TRUE), .groups = "drop" ) } } #' Create Weather Trends Multi-Panel Plot #' #' @param df Data frame with parsed GHCNh data #' @return A plotly subplot object #' @export create_weather_trends_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_dwd_data(df) # Enhancement: Calculate Dew Point if not present if (!"dew_point" %in% names(df) && "rh" %in% names(df)) { df$dew_point <- calculate_dew_point(df$temp, df$rh) # Clean dew point too if (!is.null(df$dew_point)) { df$dew_point[is.na(df$dew_point) | df$dew_point < -90 | df$dew_point > 60] <- NA_real_ } } # Ensure temporal continuity for gaps df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) date_range_vals <- c(min(df$datetime), max(df$datetime)) # Panel 1: Temperature and Dew Point has_temp <- "temp" %in% names(df) && any(!is.na(df$temp)) has_dew <- "dew_point" %in% names(df) && any(!is.na(df$dew_point)) if (has_temp || has_dew) { p1 <- plotly::plot_ly(type = "scatter", mode = "none") if (has_temp) { p1 <- p1 %>% plotly::add_lines( data = df, # Pass full data with NAs x = ~datetime, y = ~temp, name = "Temperature", line = list(color = "#e53935", width = 2), hovertemplate = "Temp: %{y:.1f}°C", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } if (has_dew) { p1 <- p1 %>% plotly::add_lines( data = df, # Pass full data with NAs x = ~datetime, y = ~dew_point, name = "Dew Point", line = list(color = "#1e88e5", width = 1.5, dash = "dot"), hovertemplate = "Dew Pt: %{y:.1f}°C", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } p1 <- p1 %>% plotly::layout(yaxis = list(title = "Temp/Dew (°C)")) } else { p1 <- plotly::plot_ly(type = "scatter", mode = "none") %>% plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>% plotly::add_annotations(text = "Temperature Not Available", showarrow = FALSE, font = list(color = "#999")) %>% plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Temp")) } # Panel 2: Wind Speed and Gusts has_wind <- "wind_speed" %in% names(df) && any(!is.na(df$wind_speed)) has_gust <- "wind_gust_max" %in% names(df) && any(!is.na(df$wind_gust_max)) if (has_wind || has_gust) { p2 <- plotly::plot_ly(type = "scatter", mode = "none") if (has_wind) { chunks <- split_into_chunks(df, "wind_speed") for (i in seq_along(chunks)) { chunk <- chunks[[i]] if (nrow(chunk) < 1) next p2 <- p2 %>% plotly::add_lines( data = chunk, x = ~datetime, y = ~wind_speed, name = "Wind Speed", fill = "tozeroy", line = list(color = "#43a047", width = 1), fillcolor = "rgba(67, 160, 71, 0.5)", hovertemplate = "Wind: %{y:.1f} m/s", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = (i == 1), legendgroup = "wind_trend" ) } } if (has_gust) { p2 <- p2 %>% plotly::add_markers( data = df %>% dplyr::filter(!is.na(wind_gust_max)), x = ~datetime, y = ~wind_gust_max, name = "Gust (Max)", marker = list(color = "#2e7d32", size = 4), hovertemplate = "Gust: %{y:.1f} m/s", type = "scatter", mode = "markers", showlegend = TRUE ) } p2 <- p2 %>% plotly::layout(yaxis = list(title = "Wind (m/s)")) } else { p2 <- plotly::plot_ly(type = "scatter", mode = "none") %>% plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>% plotly::add_annotations(text = "Wind Not Available", showarrow = FALSE, font = list(color = "#999")) %>% plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Wind")) } # Panel 3: Pressure has_msl <- "pressure" %in% names(df) && any(!is.na(df$pressure)) has_station_p <- "station_pressure" %in% names(df) && any(!is.na(df$station_pressure)) if (has_msl || has_station_p) { p3 <- plotly::plot_ly(type = "scatter", mode = "none") if (has_msl) { p3 <- p3 %>% plotly::add_lines( data = df, # Pass full data x = ~datetime, y = ~pressure, name = "Sea Level", line = list(color = "#7b1fa2", width = 1.5), hovertemplate = "MSL: %{y:.1f} hPa", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } if (has_station_p) { p3 <- p3 %>% plotly::add_lines( data = df, # Pass full data x = ~datetime, y = ~station_pressure, name = "Pressure (Station)", line = list(color = "#AB47BC", width = 1.5, dash = "dot"), hovertemplate = "Stn: %{y:.1f} hPa", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } p3 <- p3 %>% plotly::add_lines( x = date_range_vals, y = c(1013.25, 1013.25), name = "Standard (1013.25)", line = list(color = "rgba(100, 100, 100, 0.5)", width = 1, dash = "dash"), showlegend = TRUE, hoverinfo = "skip", inherit = FALSE, type = "scatter", mode = "lines" ) %>% plotly::layout(yaxis = list(title = "Pressure (hPa)")) } else { p3 <- plotly::plot_ly(type = "scatter", mode = "none") %>% plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>% plotly::add_annotations(text = "Pressure Not Available", showarrow = FALSE, font = list(color = "#999")) %>% plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Pressure")) } # Panel 4: Cloud Cover has_cloud <- "cloud_cover" %in% names(df) && any(!is.na(df$cloud_cover)) if (has_cloud) { p4 <- plotly::plot_ly(type = "scatter", mode = "none") %>% plotly::add_lines( data = df, # Pass full data x = ~datetime, y = ~cloud_cover, name = "Cloud Cover", line = list(color = "#757575", width = 1.5), hovertemplate = "Cloud: %{y} /8 octas", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) %>% plotly::layout(yaxis = list(title = "Cloud (octas)", range = c(0, 8))) } else { p4 <- plotly::plot_ly(type = "scatter", mode = "none") %>% plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>% plotly::add_annotations(text = "Cloud Cover Not Available", showarrow = FALSE, font = list(color = "#999")) %>% plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Cloud")) } # Combine all panels plotly::subplot(p1, p2, p3, p4, nrows = 4, shareX = TRUE, titleY = TRUE, margin = 0.04) %>% plotly::layout( title = list(text = paste("Weather Overview:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date", range = date_range_vals), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.05), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Temperature Plot (Single Panel) #' #' Creates a standalone temperature plot for the overview section. #' For daily data, includes Tmin and Tmax. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_temperature_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Temperature")) } # Centralized cleaning df <- clean_dwd_data(df) # Ensure continuity df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Detect if daily data (has temp_min/temp_max) or monthly (has avg variants too) has_temp <- "temp" %in% names(df) && any(!is.na(df$temp)) has_tmin <- "temp_min" %in% names(df) && any(!is.na(df$temp_min)) has_tmax <- "temp_max" %in% names(df) && any(!is.na(df$temp_max)) has_tmin_avg <- "temp_min_avg" %in% names(df) && any(!is.na(df$temp_min_avg)) has_tmax_avg <- "temp_max_avg" %in% names(df) && any(!is.na(df$temp_max_avg)) if (!has_temp && !has_tmin && !has_tmax) { return(create_empty_plot("No data available for Temperature")) } p <- plotly::plot_ly(type = "scatter", mode = "lines") # --- Monthly Resolution Tiered Visualisation --- if (has_tmin_avg && has_tmax_avg) { # 1. Outer Band (Absolute Extremes) - Lightest if (has_tmin && has_tmax) { # Lower bound of outer ribbon (Absolute Min) p <- p %>% plotly::add_trace( data = df %>% dplyr::filter(!is.na(temp_min)), x = ~datetime, y = ~temp_min, type = "scatter", mode = "lines", name = "Abs Min", line = list(color = "rgba(211, 47, 47, 0.3)", width = 1), hovertemplate = "Abs Min: %{y:.1f}°C", showlegend = FALSE ) # Upper bound of outer ribbon (Absolute Max) - filled to previous line p <- p %>% plotly::add_trace( data = df %>% dplyr::filter(!is.na(temp_max)), x = ~datetime, y = ~temp_max, type = "scatter", mode = "lines", fill = "tonexty", name = "Absolute Range", line = list(color = "rgba(211, 47, 47, 0.3)", width = 1), fillcolor = "rgba(211, 47, 47, 0.1)", hovertemplate = "Abs Max: %{y:.1f}°C", showlegend = TRUE ) } # 2. Inner Band (Average Daily Extremes) - Darker # Lower bound p <- p %>% plotly::add_trace( data = df %>% dplyr::filter(!is.na(temp_min_avg)), x = ~datetime, y = ~temp_min_avg, type = "scatter", mode = "lines", name = "Avg Min", line = list(color = "rgba(211, 47, 47, 0.5)", width = 1), hovertemplate = "Avg Daily Min: %{y:.1f}°C", showlegend = FALSE ) # Upper bound - filled to previous p <- p %>% plotly::add_trace( data = df %>% dplyr::filter(!is.na(temp_max_avg)), x = ~datetime, y = ~temp_max_avg, type = "scatter", mode = "lines", fill = "tonexty", name = "Avg Daily Range", line = list(color = "rgba(211, 47, 47, 0.5)", width = 1), fillcolor = "rgba(211, 47, 47, 0.25)", hovertemplate = "Avg Daily Max: %{y:.1f}°C", showlegend = TRUE ) } else { # --- Standard Daily/Hourly Visualisation --- # If both Tmax and Tmin are available (typical for Daily resolution), render as Ribbon # If both Tmax and Tmin are available if (has_tmin && has_tmax) { # Split data into continuous chunks based on Tmin (assuming Tmax/Tmin availability is correlated) # This works around Plotly's fill='tonexty' connecting across gaps # Helper to add trace for a chunk chunks <- split_into_chunks(df, "temp_min") for (i in seq_along(chunks)) { chunk <- chunks[[i]] if (nrow(chunk) < 1) next # Only show legend for the first chunk to avoid spam show_leg <- (i == 1) # Lower bound (Tmin) p <- p %>% plotly::add_trace( data = chunk, x = ~datetime, y = ~temp_min, type = "scatter", mode = "lines", name = "Daily Min", line = list(color = "rgba(211, 47, 47, 0.4)", width = 1), hovertemplate = "Tmin: %{y:.1f}°C", showlegend = FALSE, legendgroup = "daily_range" ) # Upper bound (Tmax) - filled to previous p <- p %>% plotly::add_trace( data = chunk, x = ~datetime, y = ~temp_max, type = "scatter", mode = "lines", fill = "tonexty", name = "Daily Range", line = list(color = "rgba(211, 47, 47, 0.4)", width = 1), fillcolor = "rgba(211, 47, 47, 0.2)", hovertemplate = "Tmax: %{y:.1f}°C", showlegend = show_leg, legendgroup = "daily_range" ) } } else { # Fallback for when only one is available (e.g. hourly with missing data) if (has_tmax) { p <- p %>% plotly::add_lines( data = df, x = ~datetime, y = ~temp_max, name = "Tmax", line = list(color = "#d32f2f", width = 2), hovertemplate = "Tmax: %{y:.1f}°C", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } if (has_tmin) { p <- p %>% plotly::add_lines( data = df, x = ~datetime, y = ~temp_min, name = "Tmin", line = list(color = "#1976d2", width = 2), hovertemplate = "Tmin: %{y:.1f}°C", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } } } # Mean temperature (Always on top) if (has_temp) { p <- p %>% plotly::add_lines( data = df, x = ~datetime, y = ~temp, name = if (has_tmin || has_tmax) "Tmean" else "Temperature", line = list( color = "#b71c1c", # Darker red for contrast width = 2, dash = "solid" ), hovertemplate = paste0(if (has_tmin || has_tmax) "Tmean" else "Temp", ": %{y:.1f}°C"), type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } p %>% plotly::layout( title = list(text = paste("Temperature:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Temperature (°C)"), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Humidity Plot (Single Panel) #' #' Creates a standalone plot for dew point and relative humidity. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_humidity_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Humidity")) } # Centralized cleaning df <- clean_dwd_data(df) # Ensure continuity df <- ensure_temporal_continuity(df, resolution = resolution) # Calculate Dew Point if not present if (!"dew_point" %in% names(df) && "rh" %in% names(df) && "temp" %in% names(df)) { df$dew_point <- calculate_dew_point(df$temp, df$rh) if (!is.null(df$dew_point)) { df$dew_point[is.na(df$dew_point) | df$dew_point < -90 | df$dew_point > 60] <- NA_real_ } } df <- ensure_temporal_continuity(df) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) has_dew <- "dew_point" %in% names(df) && any(!is.na(df$dew_point)) has_rh <- "rh" %in% names(df) && any(!is.na(df$rh)) if (!has_dew && !has_rh) { return(create_empty_plot("No data available for Humidity")) } p <- plotly::plot_ly(type = "scatter", mode = "none") if (has_dew) { p <- p %>% plotly::add_lines( data = df, x = ~datetime, y = ~dew_point, name = "Dew Point", line = list(color = "#1e88e5", width = 2), hovertemplate = "Dew Pt: %{y:.1f}°C", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } if (has_rh) { p <- p %>% plotly::add_lines( data = df, x = ~datetime, y = ~rh, name = "Relative Humidity", line = list(color = "#43a047", width = 1.5, dash = "dot"), hovertemplate = "RH: %{y:.0f}%", type = "scatter", mode = "lines", yaxis = "y2", connectgaps = FALSE, showlegend = TRUE ) } # Layout with dual y-axis if both are present layout_args <- list( title = list(text = paste("Humidity:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Dew Point (°C)"), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 60), modebar = list(orientation = "h") ) if (has_rh) { layout_args$yaxis2 <- list( title = "Relative Humidity (%)", overlaying = "y", side = "right", range = c(0, 100) ) } do.call(plotly::layout, c(list(p), layout_args)) %>% plotly::config(displaylogo = FALSE) } #' Create Wind Overview Plot (Single Panel) #' #' Creates a standalone wind speed and gusts plot for the overview section. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_wind_overview_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Wind")) } # Centralized cleaning df <- clean_dwd_data(df) df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) has_wind <- "wind_speed" %in% names(df) && any(!is.na(df$wind_speed)) has_gust <- "wind_gust_max" %in% names(df) && any(!is.na(df$wind_gust_max)) if (!has_wind && !has_gust) { return(create_empty_plot("No data available for Wind")) } p <- plotly::plot_ly(type = "scatter", mode = "none") if (has_wind) { chunks <- split_into_chunks(df, "wind_speed") for (i in seq_along(chunks)) { chunk <- chunks[[i]] if (nrow(chunk) < 1) next p <- p %>% plotly::add_lines( data = chunk, x = ~datetime, y = ~wind_speed, name = "Wind Speed", fill = "tozeroy", line = list(color = "#43a047", width = 1), fillcolor = "rgba(67, 160, 71, 0.5)", hovertemplate = "Wind: %{y:.1f} m/s", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = (i == 1), legendgroup = "wind_overview" ) } } if (has_gust) { p <- p %>% plotly::add_markers( data = df %>% dplyr::filter(!is.na(wind_gust_max)), x = ~datetime, y = ~wind_gust_max, name = "Gust (Max)", marker = list(color = "#2e7d32", size = 4), hovertemplate = "Gust: %{y:.1f} m/s", type = "scatter", mode = "markers", showlegend = TRUE ) } p %>% plotly::layout( title = list(text = paste("Wind:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Wind Speed (m/s)"), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Pressure Plot (Single Panel) #' #' Creates a standalone pressure plot for the overview section. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_pressure_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Pressure")) } # Centralized cleaning df <- clean_dwd_data(df) df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) date_range_vals <- c(min(df$datetime), max(df$datetime)) has_msl <- "pressure" %in% names(df) && any(!is.na(df$pressure)) has_station_p <- "station_pressure" %in% names(df) && any(!is.na(df$station_pressure)) if (!has_msl && !has_station_p) { return(create_empty_plot("No data available for Pressure")) } p <- plotly::plot_ly(type = "scatter", mode = "none") if (has_msl) { p <- p %>% plotly::add_lines( data = df, x = ~datetime, y = ~pressure, name = "Sea Level", line = list(color = "#7b1fa2", width = 1.5), hovertemplate = "MSL: %{y:.1f} hPa", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } if (has_station_p) { p <- p %>% plotly::add_lines( data = df, x = ~datetime, y = ~station_pressure, name = "Pressure (Station)", line = list(color = "#AB47BC", width = 1.5, dash = "dot"), hovertemplate = "Stn: %{y:.1f} hPa", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) } # Add reference line for standard pressure p <- p %>% plotly::add_lines( x = date_range_vals, y = c(1013.25, 1013.25), name = "Standard (1013.25)", line = list(color = "rgba(100, 100, 100, 0.5)", width = 1, dash = "dash"), showlegend = TRUE, hoverinfo = "skip", inherit = FALSE, type = "scatter", mode = "lines" ) p %>% plotly::layout( title = list(text = paste("Pressure:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Pressure (hPa)"), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Cloud Cover Plot (Single Panel) #' #' Creates a standalone cloud cover plot for the overview section. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_cloud_cover_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Cloud Cover")) } # Centralized cleaning df <- clean_dwd_data(df) df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) has_cloud <- "cloud_cover" %in% names(df) && any(!is.na(df$cloud_cover)) if (!has_cloud) { return(create_empty_plot("No data available for Cloud Cover")) } plotly::plot_ly(type = "scatter", mode = "none") %>% plotly::add_lines( data = df, x = ~datetime, y = ~cloud_cover, name = "Cloud Cover", line = list(color = "#757575", width = 1.5), hovertemplate = "Cloud: %{y} /8 octas", type = "scatter", mode = "lines", connectgaps = FALSE, showlegend = TRUE ) %>% plotly::layout( title = list(text = paste("Cloud Cover:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Cloud Cover (octas)", range = c(0, 8)), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Precipitation Plot #' #' Creates a stacked subplot for all available precipitation intervals. #' #' @param df Data frame with parsed GHCNh data #' @return A plotly subplot object #' @export create_precipitation_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_dwd_data(df) df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Determine resolution based on time difference between first two rows # Determine resolution based on time difference between first two rows resolution_label <- "1h" if (nrow(df) > 1) { dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours")) if (!is.na(dt)) { if (dt >= 600) { resolution_label <- "Monthly" } else if (dt >= 23) { resolution_label <- "Daily" } else if (dt < 0.9) { resolution_label <- "10 min" } } } # Valid precipitation columns (smallest interval to largest) precip_cols <- c( "precip" = resolution_label ) plot_list <- list() # Iterate through columns and create a plot if data exists for (col_name in names(precip_cols)) { if (col_name %in% names(df) && any(!is.na(df[[col_name]]))) { label <- precip_cols[[col_name]] # Prepare data p_data <- df if (nrow(p_data) > 0) { # Keep non-zero for bar visibility, or all for timeline? # User code kept all. # Check rlang dependency for !!sym. If not available, use dynamic formula or base. # Simplest: use plot_ly(x=~datetime, y=as.formula(paste0("~", col_name))) # Initialize empty plot to avoid inheriting 'mode' attributes for bar plots p <- plotly::plot_ly(data = p_data, x = ~datetime) %>% plotly::add_bars( y = as.formula(paste0("~", col_name)), name = paste("Precip", label), marker = list(color = "#0277bd"), hovertemplate = paste0(label, ": %{y:.1f} mm"), showlegend = TRUE ) %>% plotly::layout( yaxis = list(title = paste0(label, " (mm)")) ) plot_list[[length(plot_list) + 1]] <- p } } } if (length(plot_list) == 0) { return(create_empty_plot("No precipitation data available")) } # Combine plots vertically plotly::subplot(plot_list, nrows = length(plot_list), shareX = TRUE, titleY = TRUE, margin = 0.04) %>% plotly::layout( title = list(text = paste("Precipitation:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), hovermode = "x unified", hoverlabel = list(), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Wind Rose Plot #' #' Creates a polar bar chart showing wind direction and speed distribution. #' #' @param df Data frame with parsed GHCNh data #' @return A plotly polar bar chart #' @export create_wind_rose_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_dwd_data(df) if (!all(c("wind_speed", "wind_dir") %in% names(df))) { return(create_empty_plot("Wind data not available for this station")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) wind_df <- df %>% dplyr::filter(!is.na(wind_speed), !is.na(wind_dir)) %>% dplyr::mutate( dir_bin = round(wind_dir / 22.5) * 22.5, dir_bin = ifelse(dir_bin == 360, 0, dir_bin), speed_cat = cut( wind_speed, breaks = c(-0.1, 2, 5, 10, 20, Inf), labels = c("0-2", "2-5", "5-10", "10-20", "20+") ) ) %>% dplyr::group_by(dir_bin, speed_cat) %>% dplyr::summarise(count = dplyr::n(), .groups = "drop") total_obs <- sum(wind_df$count) wind_df <- wind_df %>% dplyr::mutate(percentage = count / total_obs * 100) if (nrow(wind_df) == 0) { return(create_empty_plot("No wind data available for the selected period")) } compass <- data.frame( dir_bin = seq(0, 337.5, by = 22.5), label = c( "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" ) ) p <- plotly::plot_ly(type = "barpolar") speed_levels <- levels(wind_df$speed_cat) colors <- c("#2196F3", "#4CAF50", "#FFC107", "#FF9800", "#F44336") for (i in seq_along(speed_levels)) { lvl <- speed_levels[i] lvl_df <- wind_df %>% dplyr::filter(speed_cat == lvl) if (nrow(lvl_df) > 0) { p <- p %>% plotly::add_trace( data = lvl_df, type = "barpolar", r = ~percentage, theta = ~dir_bin, name = lvl, marker = list(color = colors[i]) ) } } p %>% plotly::layout( title = list(text = paste("Wind Rose:", date_range_str), font = list(size = 14)), polar = list( angularaxis = list( rotation = 90, direction = "clockwise", tickmode = "array", tickvals = compass$dir_bin, ticktext = compass$label ), radialaxis = list(ticksuffix = "%") ), showlegend = TRUE, hoverlabel = list(), legend = list(title = list(text = "Wind Speed (m/s)"), orientation = "v"), margin = list(t = 80, b = 80, l = 40, r = 40), modebar = list(orientation = "h"), annotations = list( list( x = 0, y = 1.1, xref = "paper", yref = "paper", text = "ⓘ", # info symbol showarrow = FALSE, font = list(size = 18, color = "#666"), xanchor = "left", hovertext = "Wind rose shows the frequency distribution of wind direction and speed", hoverinfo = "text" ) ) ) %>% plotly::config(displaylogo = FALSE) } #' Create Weathergami Plot #' #' Creates a heatmap showing the frequency of daily max/min temperature #' combinations. This visualization helps identify climate patterns. #' #' @param df Data frame with parsed GHCNh data #' @return A plotly heatmap #' @export create_weathergami_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Clean data first df <- clean_dwd_data(df) # Check if daily min/max columns exist (required for weathergami) has_daily_minmax <- "temp_min" %in% names(df) && "temp_max" %in% names(df) if (!has_daily_minmax) { return(create_empty_plot("Weathergami requires daily data with Tmin/Tmax.
Please select daily resolution.")) } # Work directly with temp_min and temp_max columns (no aggregation needed) # Filter to valid data points daily_temps <- df %>% filter(!is.na(temp_min), !is.na(temp_max)) %>% mutate( date = as.Date(datetime), tmax_bin = round(temp_max), tmin_bin = round(temp_min) ) if (nrow(daily_temps) == 0) { return(create_empty_plot("No valid temperature min/max data available")) } date_range_str <- paste( format(min(daily_temps$datetime), "%d %b %Y"), "-", format(max(daily_temps$datetime), "%d %b %Y") ) # Count occurrences of each max/min combination freq_table <- daily_temps %>% dplyr::group_by(tmax_bin, tmin_bin) %>% dplyr::summarise(count = dplyr::n(), .groups = "drop") # Create the heatmap using plotly - use add_heatmap for proper trace handling p <- plotly::plot_ly(type = "heatmap") %>% plotly::add_heatmap( data = freq_table, x = ~tmax_bin, y = ~tmin_bin, z = ~count, colors = c("#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08519c", "#08306b"), colorbar = list(title = "Days"), hovertemplate = paste0( "Tmax: %{x}°C
", "Tmin: %{y}°C
", "Days: %{z}" ) ) # Add diagonal tmin_r <- range(freq_table$tmin_bin) tmax_r <- range(freq_table$tmax_bin) diag_range <- seq(min(tmin_r, tmax_r) - 2, max(tmin_r, tmax_r) + 2) p <- p %>% plotly::add_lines( x = diag_range, y = diag_range, line = list(color = "rgba(150, 150, 150, 0.5)", width = 1, dash = "dash"), name = "Tmax = Tmin", showlegend = TRUE, hoverinfo = "skip", inherit = FALSE ) p %>% plotly::layout( title = list( text = paste("Weathergami:", date_range_str), font = list(size = 14) ), xaxis = list( title = "Daily Maximum Temperature (°C)", zeroline = FALSE ), yaxis = list( title = "Daily Minimum Temperature (°C)", zeroline = FALSE, scaleanchor = "x", scaleratio = 1 ), hovermode = "closest", hoverlabel = list() ) %>% plotly::config(displaylogo = FALSE) } #' Create Diurnal Temperature Cycle Plot #' #' Creates a plot showing temperature variation by hour of day. #' #' @param df Data frame with parsed GHCNh data #' @return A plotly line chart #' @export create_diurnal_plot <- function(df, offset_hours = 0) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Basic Diurnal Plot (UTC for now, offset not fully piped in from UI yet) df_diurnal <- df %>% dplyr::filter(!is.na(temp)) %>% dplyr::mutate( local_time = datetime + (offset_hours * 3600), date = as.Date(local_time), hour_val = lubridate::hour(local_time) ) if (nrow(df_diurnal) == 0) { return(create_empty_plot("No temperature data available for diurnal plot")) } median_cycle <- df_diurnal %>% dplyr::group_by(hour_val) %>% dplyr::summarise(median_temp = median(temp, na.rm = TRUE), .groups = "drop") date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Sample background lines unique_dates <- sort(unique(df_diurnal$date)) sampled_dates <- unique_dates[seq(1, length(unique_dates), length.out = min(length(unique_dates), 100))] # Capp at 100 lines for perf df_bg <- df_diurnal %>% filter(date %in% sampled_dates) plotly::plot_ly(type = "scatter", mode = "none", showlegend = FALSE, hoverinfo = "none") %>% plotly::add_lines( data = df_bg, x = ~hour_val, y = ~temp, split = ~date, line = list(color = "rgba(150, 150, 150, 0.2)", width = 0.5), hoverinfo = "none", showlegend = FALSE, type = "scatter", mode = "lines" ) %>% plotly::add_lines( data = median_cycle, x = ~hour_val, y = ~median_temp, name = "Median Cycle", line = list(color = "#d32f2f", width = 4), hovertemplate = "Hour: %{x}:00
Median: %{y:.1f}°C", type = "scatter", mode = "lines" ) %>% plotly::layout( title = list(text = paste("Diurnal Cycle:", date_range_str), font = list(size = 14)), xaxis = list(title = "Hour (UTC+Offset)"), yaxis = list(title = "Temperature (°C)"), showlegend = TRUE, hoverlabel = list() ) %>% plotly::config(displaylogo = FALSE) } #' Create Solar Radiation Plot (Single Panel) #' #' Creates a standalone solar radiation plot for the solar section. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_solar_radiation_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Solar Radiation")) } # Centralized cleaning df <- clean_dwd_data(df) df <- ensure_temporal_continuity(df, resolution = resolution) has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global)) if (!has_global) { return(create_empty_plot("No data available for Solar Radiation")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Detect resolution resolution_label <- "Hourly" if (nrow(df) > 1) { dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours")) if (!is.na(dt)) { if (dt >= 23) { resolution_label <- "Daily" } else if (dt < 0.9) { resolution_label <- "10 min" } } } p <- plotly::plot_ly() chunks <- split_into_chunks(df, "solar_global") for (i in seq_along(chunks)) { chunk <- chunks[[i]] if (nrow(chunk) < 1) next p <- p %>% plotly::add_trace( data = chunk, x = ~datetime, y = ~solar_global, name = "Global Radiation", type = "scatter", mode = "lines", fill = "tozeroy", line = list(color = "rgba(255, 179, 0, 0.9)", width = 1), fillcolor = "rgba(255, 179, 0, 0.3)", hovertemplate = "Solar: %{y:,.0f} J/cm²", connectgaps = FALSE, showlegend = (i == 1), legendgroup = "solar_rad" ) } p %>% plotly::layout( title = list(text = paste(resolution_label, "Solar Radiation:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Radiation (J/cm²)"), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Sunshine Duration Plot (Single Panel) #' #' Creates a standalone sunshine duration plot for the solar section. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_sunshine_duration_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Sunshine Duration")) } # Centralized cleaning df <- clean_dwd_data(df) df <- ensure_temporal_continuity(df, resolution = resolution) has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration)) if (!has_sun) { return(create_empty_plot("No data available for Sunshine Duration")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Use full dataframe range for x-axis consistency with other plots date_range_vals <- c(min(df$datetime), max(df$datetime)) # Detect resolution resolution_label <- "Hourly" y_range <- c(0, 65) y_title <- "Sunshine (min)" if (nrow(df) > 1) { dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours")) if (!is.na(dt)) { if (dt >= 600) { resolution_label <- "Monthly" y_range <- NULL # Autoscale for monthly y_title <- "Sunshine (hours)" } else if (dt >= 23) { resolution_label <- "Daily" y_range <- c(0, 24) y_title <- "Sunshine (hours)" } else if (dt < 0.9) { resolution_label <- "10 min" y_range <- NULL # Autoscale y_title <- "Sunshine (min)" } } } plotly::plot_ly( data = df, x = ~datetime, y = ~sunshine_duration, name = "Sunshine Duration", type = "bar", marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)), hovertemplate = paste0("Sunshine: %{y:.1f} ", if (resolution_label %in% c("Daily", "Monthly")) "h" else "min", "") ) %>% plotly::layout( title = list(text = paste(resolution_label, "Sunshine Duration:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date", range = date_range_vals), yaxis = list(title = y_title, range = y_range), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Solar Plot (Enhanced - Daily Aggregation) #' Create Solar Plot (Hourly) #' #' Plots raw hourly solar radiation and sunshine duration. #' @param df Cleaned data frame create_solar_plot_hourly <- function(df, resolution = NULL) { # Cleaning already done by caller has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global)) has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration)) df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) date_range_vals <- c(min(df$datetime), max(df$datetime)) # Panel 1: Global Radiation (Filled Area) if (has_global) { # Using full df to preserve gaps p1 <- plotly::plot_ly() chunks <- split_into_chunks(df, "solar_global") for (i in seq_along(chunks)) { chunk <- chunks[[i]] if (nrow(chunk) < 1) next p1 <- p1 %>% plotly::add_trace( data = chunk, x = ~datetime, y = ~solar_global, name = "Global Radiation", type = "scatter", mode = "lines", fill = "tozeroy", line = list(color = "rgba(255, 179, 0, 0.9)", width = 1), fillcolor = "rgba(255, 179, 0, 0.3)", hovertemplate = "Solar: %{y:,.0f} J/cm²", connectgaps = FALSE, showlegend = (i == 1), legendgroup = "solar_hourly" ) } p1 <- p1 %>% plotly::layout(yaxis = list(title = "Radiation (J/cm²)")) } else { p1 <- create_empty_plot("No Radiation Data") %>% plotly::layout(yaxis = list(title = "Radiation")) } # Panel 2: Sunshine Duration (Bars) if (has_sun) { # Using full df p2 <- plotly::plot_ly( data = df, x = ~datetime, y = ~sunshine_duration, name = "Sunshine Duration", type = "bar", marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)), hovertemplate = "Sunshine: %{y:.0f} min" ) %>% plotly::layout(yaxis = list(title = "Sunshine (min)", range = c(0, 65))) } else { p2 <- create_empty_plot("No Sunshine Data") %>% plotly::layout(yaxis = list(title = "Sunshine")) } # Combine plotly::subplot(p1, p2, nrows = 2, shareX = TRUE, titleY = TRUE, margin = 0.04) %>% plotly::layout( title = list(text = paste("Hourly Solar & Sunshine:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date", range = date_range_vals), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 60, b = 60, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Solar Plot (Daily Aggregation) #' #' @param df Cleaned data frame create_solar_plot_daily <- function(df, resolution = NULL) { # Cleaning already done by caller has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global)) has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration)) # --- Daily Aggregation --- df_daily <- df %>% dplyr::mutate(date = as.Date(datetime)) %>% dplyr::group_by(date) %>% dplyr::summarise(.groups = "drop") if (has_global) { solar_agg <- df %>% dplyr::mutate(date = as.Date(datetime)) %>% dplyr::group_by(date) %>% dplyr::summarise( daily_solar = sum(solar_global, na.rm = TRUE), .groups = "drop" ) df_daily <- dplyr::left_join(df_daily, solar_agg, by = "date") } if (has_sun) { sun_agg <- df %>% dplyr::mutate(date = as.Date(datetime)) %>% dplyr::group_by(date) %>% dplyr::summarise( daily_sun_hours = sum(sunshine_duration, na.rm = TRUE), .groups = "drop" ) df_daily <- dplyr::left_join(df_daily, sun_agg, by = "date") } # Ensure continuity on daily aggregated data # (Re-using helper with dummy datetime at midday to serve as date check, or simplier: use same helper since it handles dt >= 23h as daily) # The helper expects 'datetime' column. df_daily <- df_daily %>% dplyr::mutate(datetime = as.POSIXct(date)) # Dummy datetime for helper df_daily <- ensure_temporal_continuity(df_daily, resolution = "daily") df_daily$date <- as.Date(df_daily$datetime) # Sync back date_range_str <- paste( format(min(df_daily$date), "%d %b %Y"), "-", format(max(df_daily$date), "%d %b %Y") ) date_range_vals <- c(min(df_daily$date), max(df_daily$date)) # Panel 1: Global Radiation if (has_global) { p1 <- plotly::plot_ly() chunks <- split_into_chunks(df_daily, "daily_solar") for (i in seq_along(chunks)) { chunk <- chunks[[i]] if (nrow(chunk) < 1) next p1 <- p1 %>% plotly::add_trace( data = chunk, x = ~date, y = ~daily_solar, name = "Daily Global Radiation", type = "scatter", mode = "lines", fill = "tozeroy", line = list(color = "rgba(255, 179, 0, 0.9)", width = 1), # Amber fillcolor = "rgba(255, 179, 0, 0.3)", hovertemplate = "Daily Rad: %{y:,.0f} J/cm²", connectgaps = FALSE, showlegend = (i == 1), legendgroup = "daily_solar" ) } p1 <- p1 %>% plotly::layout(yaxis = list(title = "Radiation (J/cm²)")) } else { p1 <- create_empty_plot("No Radiation Data") %>% plotly::layout(yaxis = list(title = "Radiation")) } # Panel 2: Sunshine Duration if (has_sun) { p2 <- plotly::plot_ly( data = df_daily, x = ~date, y = ~daily_sun_hours, name = "Daily Sunshine", type = "bar", marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)), # Gold/Yellow outline hovertemplate = "Sunshine: %{y:.1f} h" ) %>% plotly::layout(yaxis = list(title = "Sunshine (Hours)", range = if (any(df_daily$daily_sun_hours > 24, na.rm = TRUE)) NULL else c(0, 24))) } else { p2 <- create_empty_plot("No Sunshine Data") %>% plotly::layout(yaxis = list(title = "Sunshine")) } # Combine plotly::subplot(p1, p2, nrows = 2, shareX = TRUE, titleY = TRUE, margin = 0.04) %>% plotly::layout( title = list(text = paste("Daily Solar Energy & Sunshine:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date", range = date_range_vals), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 60, b = 60, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Solar Plot (Dynamic) #' #' Automatically detects hourly vs daily data and dispatches plot. #' #' @param df Data frame with parsed GHCNh data #' @return A plotly object #' @export create_solar_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_dwd_data(df) has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global)) has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration)) if (!has_global && !has_sun) { return(create_empty_plot("No solar/sunshine data in selected time range")) } # Determine resolution is_daily <- FALSE if (!is.null(resolution)) { if (tolower(resolution) == "daily") is_daily <- TRUE } else { # Fallback detection if (nrow(df) > 1) { dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours")) if (!is.na(dt) && dt >= 23) { is_daily <- TRUE } } } if (is_daily) { # Already daily data, pass to daily plotter which will just use available cols # Note: If input is already daily, creating summation again is fine (group size 1) # OR we can assume input 'df' is daily and just plot it. # However, our daily plotter assumes it needs to SUM hourly data. # Let's adjust daily plotter to just use provided values if it IS daily already? # Actually simplest is: if incoming IS daily, we don't need to summarize 'solar_global' as sum, # but 'solar_global' in daily CSV is usually sum already. # Let's check DWD logic: # Hourly: solar_global sum over hour. # Daily: solar_global sum over day. # So aggregation logic works for both: sum(x) where x is already daily sum = x. create_solar_plot_daily(df, resolution = resolution) } else { create_solar_plot_hourly(df, resolution = resolution) } } #' Create Snow Depth and Water Equivalent Plot #' #' Creates a combined plot showing snow depth (cm) and snow water equivalent (mm). #' Primarily useful for daily resolution data. #' #' @param df Data frame with parsed DWD data #' @return A plotly object #' @export create_snow_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_dwd_data(df) has_depth <- "snow_depth" %in% names(df) && any(!is.na(df$snow_depth)) has_swe <- "snow_water_equiv" %in% names(df) && any(!is.na(df$snow_water_equiv)) has_fresh_sum <- "snow_fresh_sum" %in% names(df) && any(!is.na(df$snow_fresh_sum)) has_depth_sum <- "snow_depth_sum" %in% names(df) && any(!is.na(df$snow_depth_sum)) if (!has_depth && !has_swe && !has_fresh_sum && !has_depth_sum) { return(create_empty_plot("No Snow data in selected time range")) } df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) p <- plotly::plot_ly() # --- Monthly Resolution (Sum data) --- if (has_fresh_sum || has_depth_sum) { # Snow Depth Sum (Bars) - Primary now if (has_depth_sum) { p <- p %>% plotly::add_bars( data = df %>% dplyr::filter(!is.na(snow_depth_sum)), x = ~datetime, y = ~snow_depth_sum, name = "Snow Depth Sum", marker = list(color = "#1565c0"), hovertemplate = "Depth Sum: %{y:.1f} cm" ) } # Note: Fresh Snow Sum removed from monthly plot as per user request layout_args <- list( title = list(text = paste("Monthly Snow Summary:", date_range_str), font = list(size = 14)), xaxis = list(title = list(text = ""), type = "date"), yaxis = list(title = "Snow Depth Sum (cm)", min = 0), hovermode = "x unified", legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20) ) p <- do.call(plotly::layout, c(list(p), layout_args)) return(p %>% plotly::config(displaylogo = FALSE)) } # --- Daily/Hourly Resolution --- # Snow Depth (Area) if (has_depth) { p <- p %>% plotly::add_trace( data = df, # Full dataset x = ~datetime, y = ~snow_depth, name = "Snow Depth", type = "scatter", mode = "lines", fill = "tozeroy", line = list(color = "rgba(144, 202, 249, 1)", width = 1), fillcolor = "rgba(144, 202, 249, 0.4)", hovertemplate = "Depth: %{y:.1f} cm", connectgaps = FALSE ) } # Snow Water Equivalent (Line) if (has_swe) { p <- p %>% plotly::add_lines( data = df, # Full dataset x = ~datetime, y = ~snow_water_equiv, name = "Water Equiv.", line = list(color = "#1565c0", width = 2), hovertemplate = "SWE: %{y:.1f} mm", connectgaps = FALSE ) } p %>% plotly::layout( title = list(text = paste("Snow Depth:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Snow Depth (cm) / SWE (mm)", min = 0), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Soil Temperature Profile Plot #' #' Creates a multi-line plot showing soil temperatures at different depths. #' Available depths: 2cm, 5cm, 10cm, 20cm, 50cm. #' #' @param df Data frame with parsed DWD data #' @return A plotly object #' @export create_soil_temp_plot <- function(df, resolution = NULL) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_dwd_data(df) df <- ensure_temporal_continuity(df, resolution = resolution) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Define soil temp columns and their display properties soil_cols <- list( soil_temp_2cm = list(name = "2 cm", color = "#d32f2f"), soil_temp_5cm = list(name = "5 cm", color = "#f57c00"), soil_temp_10cm = list(name = "10 cm", color = "#fbc02d"), soil_temp_20cm = list(name = "20 cm", color = "#388e3c"), soil_temp_50cm = list(name = "50 cm", color = "#1976d2"), soil_temp_100cm = list(name = "100 cm", color = "#6a1b9a") ) # Check which columns have data available_cols <- names(soil_cols)[sapply(names(soil_cols), function(col) { col %in% names(df) && any(!is.na(df[[col]])) })] if (length(available_cols) == 0) { return(create_empty_plot("No soil temperature data available for this station")) } p <- plotly::plot_ly(type = "scatter", mode = "none") for (col in available_cols) { col_info <- soil_cols[[col]] # Use full data to preserve gaps p <- p %>% plotly::add_trace( data = df, x = ~datetime, y = as.formula(paste0("~", col)), name = col_info$name, type = "scatter", mode = "lines", line = list(color = col_info$color, width = 1.5), hovertemplate = paste0(col_info$name, ": %{y:.1f}°C"), connectgaps = FALSE, showlegend = TRUE ) } # Add freezing line reference date_range_vals <- c(min(df$datetime), max(df$datetime)) p <- p %>% plotly::add_lines( x = date_range_vals, y = c(0, 0), name = "Freezing", line = list(color = "rgba(100, 100, 100, 0.5)", width = 1, dash = "dash"), showlegend = TRUE, hoverinfo = "skip", inherit = FALSE, type = "scatter", mode = "lines" ) p %>% plotly::layout( title = list(text = paste("Soil Temperature Profile:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Soil Temperature (°C)"), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) }