# helpers_plotting.R library(dplyr) library(plotly) library(lubridate) #' 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) } #' Generate a plot axis label with units from dataframe attributes #' #' @param df Dataframe with 'units' attribute #' @param col_name Column name to look up unit for #' @param default_label Default label text if unit is missing #' @return Formatted label string: "Label [unit]" get_axis_label <- function(df, col_name, default_label = NULL) { units <- attr(df, "units") # If no default label provided, generate from column name if (is.null(default_label)) { base_label <- col_name %>% gsub("_", " ", .) %>% stringr::str_to_title() } else { base_label <- default_label } # Append unit if found in the attribute map if (!is.null(units) && !is.null(units[[col_name]])) { return(paste0(base_label, " [", units[[col_name]], "]")) } return(base_label) } #' 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 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) 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 Météo-France Data #' #' Ensures all weather columns are numeric and that Météo-France-specific #' flag values (8000, 9999, -999) are converted to NA. #' #' @param df Data frame to clean #' @return Cleaned data frame clean_weather_data <- function(df) { if (is.null(df) || nrow(df) == 0) { return(df) } # Weather columns to check weather_cols <- c("temp", "temp_min", "temp_max", "temp_min_abs", "temp_max_abs", "rh", "precip", "wind_speed", "wind_dir", "pressure", "station_pressure", "cloud_cover", "wind_gust_max", "solar_global", "sunshine_duration") # 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 ("temp_min" %in% names(df)) df$temp_min[df$temp_min < -90 | df$temp_min > 60] <- NA_real_ if ("temp_max" %in% names(df)) df$temp_max[df$temp_max < -90 | df$temp_max > 60] <- NA_real_ if ("temp_min_abs" %in% names(df)) df$temp_min_abs[df$temp_min_abs < -90 | df$temp_min_abs > 60] <- NA_real_ if ("temp_max_abs" %in% names(df)) df$temp_max_abs[df$temp_max_abs < -90 | df$temp_max_abs > 60] <- NA_real_ if ("temp_min_ground" %in% names(df)) df$temp_min_ground[df$temp_min_ground < -90 | df$temp_min_ground > 70] <- NA_real_ if ("temp_min_50cm" %in% names(df)) df$temp_min_50cm[df$temp_min_50cm < -90 | df$temp_min_50cm > 60] <- NA_real_ if ("rh" %in% names(df)) df$rh[df$rh < 0 | df$rh > 100] <- NA_real_ if ("rh_min" %in% names(df)) df$rh_min[df$rh_min < 0 | df$rh_min > 100] <- NA_real_ if ("rh_max" %in% names(df)) df$rh_max[df$rh_max < 0 | df$rh_max > 100] <- NA_real_ if ("precip" %in% names(df)) df$precip[df$precip < 0 | df$precip > 500] <- NA_real_ if ("precip_duration" %in% names(df)) df$precip_duration[df$precip_duration < 0 | df$precip_duration > 1440] <- NA_real_ # Max 24h in minutes 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 ("cloud_cover" %in% names(df)) df$cloud_cover[df$cloud_cover < 0 | df$cloud_cover > 9] <- NA_real_ if ("snow_depth" %in% names(df)) df$snow_depth[df$snow_depth < 0 | df$snow_depth > 1000] <- NA_real_ if ("snow_fresh" %in% names(df)) df$snow_fresh[df$snow_fresh < 0 | df$snow_fresh > 500] <- NA_real_ if ("etp" %in% names(df)) df$etp[df$etp < 0 | df$etp > 600] <- 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_weather_data(df) # Check if we already have daily min/max (from daily Météo-France 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 (Météo-France daily data: TN, TX) 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) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_weather_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_ } } 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 %>% dplyr::filter(!is.na(temp)), x = ~datetime, y = ~temp, name = "Temperature", line = list(color = "#e53935", width = 2), hovertemplate = "Temp: %{y:.1f}°C", type = "scatter", mode = "lines", showlegend = TRUE ) } if (has_dew) { p1 <- p1 %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(dew_point)), 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", 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) { p2 <- p2 %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(wind_speed)), x = ~datetime, y = ~wind_speed, name = "Wind Speed", fill = "tozeroy", line = list(color = "#43a047", width = 1), hovertemplate = "Wind: %{y:.1f} m/s", type = "scatter", mode = "lines", showlegend = TRUE ) } 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 %>% dplyr::filter(!is.na(pressure)), x = ~datetime, y = ~pressure, name = "Sea Level", line = list(color = "#7b1fa2", width = 1.5), hovertemplate = "MSL: %{y:.1f} hPa", type = "scatter", mode = "lines", showlegend = TRUE ) } if (has_station_p) { p3 <- p3 %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(station_pressure)), 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", 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 & Humidity has_cloud <- "cloud_cover" %in% names(df) && any(!is.na(df$cloud_cover)) has_rh <- "rh" %in% names(df) && any(!is.na(df$rh)) if (has_cloud || has_rh) { p4 <- plotly::plot_ly(type = "scatter", mode = "none") if (has_cloud) { # Use markers instead of lines to handle sparse data correctly (avoid misleading interpolation) p4 <- p4 %>% plotly::add_trace( data = df %>% dplyr::filter(!is.na(cloud_cover)), x = ~datetime, y = ~cloud_cover, name = "Cloud Cover", marker = list(color = "#757575", size = 4, symbol = "circle"), hovertemplate = "Cloud: %{y} /8 octas", type = "scatter", mode = "markers" ) } if (has_rh) { p4 <- p4 %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(rh)), x = ~datetime, y = ~rh, name = "Humidity", line = list(color = "#00bcd4", width = 1.5, dash = "dot"), hovertemplate = "Humidity: %{y:.1f}%", yaxis = "y2" ) } p4 <- p4 %>% plotly::layout( yaxis = list(title = "Cloud (octas)", range = c(0, 8.5)), yaxis2 = list(title = "Humidity (%)", overlaying = "y", side = "right", range = c(0, 105), showgrid = FALSE) ) } 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/Humidity 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.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 = "hourly") { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for the selected period")) } # Centralized cleaning df <- clean_weather_data(df) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Valid precipitation columns (smallest interval to largest) label_suffix <- if (resolution == "daily") "Daily" else "1h" precip_cols <- c( "precip" = paste0("Precip (", label_suffix, ")"), "snow_fresh" = "Fresh Snow (mm)", "snow_depth" = "Snow Depth (cm)" ) 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 # Use standard evaluation with .data or base syntax to avoid rlang issues if package not loaded p_data <- df[!is.na(df[[col_name]]), ] 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_weather_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, 4, 6, 8, 10, 12, 14, 16, Inf), labels = c("0-2", "2-4", "4-6", "6-8", "8-10", "10-12", "12-14", "14-16", "16+") ) ) %>% 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) # Meteorological scale: 9 distinct bins (green → teal → blue → yellow → orange → red) colors <- c("#a5d6a7", "#66bb6a", "#26a69a", "#0288d1", "#ffeb3b", "#ffc107", "#ff9800", "#f44336", "#b71c1c") 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, tickfont = list(size = 12) ), radialaxis = list( ticksuffix = "%", tickfont = list(size = 10) ) ), showlegend = TRUE, hoverlabel = list(), legend = list( title = list(text = "Wind Speed (m/s)", side = "top"), orientation = "h", x = 0.5, xanchor = "center", y = -0.2 ), margin = list(t = 50, b = 60, l = 20, r = 20), modebar = list(orientation = "h"), annotations = list( list( x = 0, y = 1.15, 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", # Added name to select it in JS later name = "info_annotation" ) ) ) %>% 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_weather_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 Plot (Matching DWD Style) #' #' @param df Cleaned data frame #' @return A plotly subplot object #' @export create_solar_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_weather_data(df) # Detect Resolution is_hourly <- TRUE if (nrow(df) > 1) { # Check average time difference head_df <- df[1:min(10, nrow(df)), ] dt <- mean(as.numeric(difftime(head_df$datetime[2:nrow(head_df)], head_df$datetime[1:(nrow(head_df) - 1)], units = "hours")), na.rm = TRUE) if (!is.na(dt) && dt > 20) is_hourly <- FALSE } else { # Fallback for single row: assume hourly or strictly follow data format? # If it has AAAAMMJJ only and datetime is 00:00, it's daily. # But here we only have datetime posix. # Let's default to hourly view unless we know better. } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Prepare Data plot_data <- df # Check availability 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)) has_etp <- "etp" %in% names(df) && any(!is.na(df$etp)) if (!has_global && !has_sun && !has_etp) { return(create_empty_plot("No solar/sun/etp data in selected time range")) } # --- Panel 1: Global Radiation --- if (has_global) { p1 <- plotly::plot_ly( data = plot_data, x = ~datetime, y = ~solar_global, name = if (is_hourly) "Global Radiation" else "Daily 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²" ) %>% plotly::layout(yaxis = list(title = "Rad (J/cm²)")) } else { p1 <- create_empty_plot("No Radiation Data") %>% plotly::layout(yaxis = list(title = "Rad")) } # --- Panel 2: Sunshine Duration --- if (has_sun) { if (!is_hourly) { # Convert minutes to hours for daily view plot_data <- plot_data %>% mutate(sun_display = sunshine_duration / 60) y_title <- "Sun (h)" hv_temp <- "Sunshine: %{y:.1f} h" y_range <- NULL # Auto } else { plot_data <- plot_data %>% mutate(sun_display = sunshine_duration) y_title <- "Sun (min)" hv_temp <- "Sunshine: %{y:.0f} min" y_range <- c(0, 65) } p2 <- plotly::plot_ly( data = plot_data, x = ~datetime, y = ~sun_display, name = if (is_hourly) "Sunshine" else "Daily Sunshine", type = "bar", marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)), hovertemplate = hv_temp ) %>% plotly::layout(yaxis = list(title = y_title, range = y_range)) } else { p2 <- create_empty_plot("No Sunshine Data") %>% plotly::layout(yaxis = list(title = "Sun")) } # --- Panel 3: ETP (if available) --- p3 <- NULL if (has_etp) { p3 <- plotly::plot_ly( data = plot_data, x = ~datetime, y = ~etp, name = "ETP", type = "scatter", mode = "lines", line = list(color = "#795548", width = 1.5, dash = "dash"), hovertemplate = "ETP: %{y:.1f} mm" ) %>% plotly::layout(yaxis = list(title = "ETP (mm)")) } # Combine plot_list <- list(p1, p2) rows_cnt <- 2 if (!is.null(p3)) { plot_list[[3]] <- p3 rows_cnt <- 3 } plotly::subplot(plot_list, nrows = rows_cnt, shareX = TRUE, titleY = TRUE, margin = 0.04) %>% plotly::layout( title = list(text = paste("Solar Radiation & Sunshine:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 60, b = 60, 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_single_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Temperature")) } df <- clean_weather_data(df) date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) 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_abs <- "temp_min_abs" %in% names(df) && any(!is.na(df$temp_min_abs)) has_tmax_abs <- "temp_max_abs" %in% names(df) && any(!is.na(df$temp_max_abs)) 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 (Absolute Extremes) --- if (has_tmin_abs && has_tmax_abs) { # 1. Outer Band (Absolute Extremes) - Lightest # Lower bound of outer ribbon (Absolute Min) p <- p %>% plotly::add_trace( data = df %>% dplyr::filter(!is.na(temp_min_abs)), x = ~datetime, y = ~temp_min_abs, 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_abs)), x = ~datetime, y = ~temp_max_abs, 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 ) } # --- Daily/Monthly Average Extremes Visualisation --- if (has_tmin && has_tmax) { # Split data into continuous chunks to correctly handle gaps with ribbons # NOTE: `split_into_chunks` is a helper function assumed to be defined elsewhere. # It should take a dataframe and a column name, and return a list of dataframes, # where each dataframe represents a continuous sequence of non-NA values in the specified column. 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 show_leg <- (i == 1) # Lower bound (Tmin) p <- p %>% plotly::add_trace( data = chunk, x = ~datetime, y = ~temp_min, type = "scatter", mode = "lines", name = "Min/Max Range", 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 = "Min/Max 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: Plot lines individually if only one extreme is available if (has_tmin) { p <- p %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(temp_min)), x = ~datetime, y = ~temp_min, name = "Mean Min", line = list(color = "#1976d2", width = 2), hovertemplate = "Mean Min: %{y:.1f}°C", showlegend = TRUE ) } if (has_tmax) { p <- p %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(temp_max)), x = ~datetime, y = ~temp_max, name = "Mean Max", line = list(color = "#d32f2f", width = 2), hovertemplate = "Mean Max: %{y:.1f}°C", showlegend = TRUE ) } } # Mean temperature (hourly temp or daily mean) - Always on top if (has_temp) { p <- p %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(temp)), x = ~datetime, y = ~temp, name = if (has_tmin || has_tmax) "Tmean" else "Temperature", line = list( color = "#b71c1c", width = 2, dash = if (has_tmin || has_tmax) "solid" else "solid" ), hovertemplate = paste0(if (has_tmin || has_tmax) "Tmean" else "Temp", ": %{y:.1f}°C"), type = "scatter", mode = "lines", 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 = get_axis_label(df, "temp", "Temperature")), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), 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_single_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Humidity")) } # Centralized cleaning df <- clean_weather_data(df) # 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_ } } 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 %>% dplyr::filter(!is.na(dew_point)), x = ~datetime, y = ~dew_point, name = "Dew Point", line = list(color = "#1e88e5", width = 2), hovertemplate = "Dew Pt: %{y:.1f}°C", type = "scatter", mode = "lines", showlegend = TRUE ) } if (has_rh) { p <- p %>% plotly::add_lines( data = df %>% dplyr::filter(!is.na(rh)), 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", 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 = get_axis_label(df, "dew_point", "Dew Point")), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 60), modebar = list(orientation = "h") ) if (has_rh) { layout_args$yaxis2 <- list( title = get_axis_label(df, "rh", "Relative Humidity"), overlaying = "y", side = "right", range = c(0, 100) ) } do.call(plotly::layout, c(list(p), layout_args)) %>% plotly::config(displaylogo = FALSE) } #' Create Solar Radiation Plot (Individual) #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_solar_radiation_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global)) if (!has_global) { return(create_empty_plot("No solar radiation data available")) } # Detect resolution is_hourly <- TRUE if (nrow(df) > 1) { head_df <- df[1:min(10, nrow(df)), ] dt <- mean(as.numeric(difftime(head_df$datetime[2:nrow(head_df)], head_df$datetime[1:(nrow(head_df) - 1)], units = "hours")), na.rm = TRUE) if (!is.na(dt) && dt > 20) is_hourly <- FALSE } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) plotly::plot_ly( data = df, x = ~datetime, y = ~solar_global, name = if (is_hourly) "Global Radiation" else "Daily 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²" ) %>% plotly::layout( title = list(text = paste("Solar Radiation:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = get_axis_label(df, "solar_global", "Radiation")), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Sunshine Duration Plot (Individual) #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_sunshine_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration)) if (!has_sun) { return(create_empty_plot("No sunshine duration data available")) } # Detect resolution is_hourly <- TRUE if (nrow(df) > 1) { head_df <- df[1:min(10, nrow(df)), ] dt <- mean(as.numeric(difftime(head_df$datetime[2:nrow(head_df)], head_df$datetime[1:(nrow(head_df) - 1)], units = "hours")), na.rm = TRUE) if (!is.na(dt) && dt > 20) is_hourly <- FALSE } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Convert to appropriate units if (!is_hourly) { df <- df %>% mutate(sun_display = sunshine_duration / 60) y_title <- "Sunshine (h)" hv_temp <- "Sunshine: %{y:.1f} h" y_range <- NULL } else { df <- df %>% mutate(sun_display = sunshine_duration) y_title <- "Sunshine (min)" hv_temp <- "Sunshine: %{y:.0f} min" y_range <- c(0, 65) } plotly::plot_ly( data = df, x = ~datetime, y = ~sun_display, name = if (is_hourly) "Sunshine" else "Daily Sunshine", type = "bar", marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)), hovertemplate = hv_temp ) %>% plotly::layout( title = list(text = paste("Sunshine Duration:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = y_title, range = y_range), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create ETP Plot (Individual) #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_etp_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_etp <- "etp" %in% names(df) && any(!is.na(df$etp)) if (!has_etp) { return(create_empty_plot("No ETP data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) plotly::plot_ly( data = df, x = ~datetime, y = ~etp, name = "ETP", type = "scatter", mode = "lines", line = list(color = "#795548", width = 1.5), hovertemplate = "ETP: %{y:.1f} mm" ) %>% plotly::layout( title = list(text = paste("Evapotranspiration:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = get_axis_label(df, "etp", "ETP")), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Precipitation Plot (Individual - precipitation only) #' #' @param df Data frame with parsed weather data #' @param resolution Resolution string ("hourly" or "daily") #' @return A plotly object #' @export create_precipitation_only_plot <- function(df, resolution = "hourly") { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_precip <- "precip" %in% names(df) && any(!is.na(df$precip)) if (!has_precip) { return(create_empty_plot("No precipitation data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) label_suffix <- if (resolution == "daily") "Daily" else "1h" axis_title <- get_axis_label(df, "precip", paste0("Precipitation (", label_suffix, ")")) if (resolution != "daily") { alias_map <- attr(df, "preferred_alias_map") precip_source <- NA_character_ if (!is.null(alias_map) && length(alias_map) > 0) { precip_alias <- alias_map["precipitation_amount"] rain_alias <- alias_map["rainfall_amount"] if (!is.null(precip_alias) && length(precip_alias) == 1 && !is.na(precip_alias)) { precip_source <- precip_alias } else if (!is.null(rain_alias) && length(rain_alias) == 1 && !is.na(rain_alias)) { precip_source <- rain_alias } } is_1h_accum <- is.character(precip_source) && length(precip_source) == 1 && !is.na(precip_source) && grepl("^precip_1h($|_)", precip_source, ignore.case = TRUE) # Fallback: infer 1h accumulation by matching the plotted 'precip' series # against known 1h source columns when alias metadata is unavailable. if (!is_1h_accum && "precip" %in% names(df)) { one_h_cols <- names(df)[grepl( "^(precip_1h($|_)|rainfall_amount(_lvl[^_]+)?_1h$)", names(df), ignore.case = TRUE )] if (length(one_h_cols) > 0) { p_vals <- suppressWarnings(as.numeric(df$precip)) for (col_name in one_h_cols) { c_vals <- suppressWarnings(as.numeric(df[[col_name]])) ok <- is.finite(p_vals) & is.finite(c_vals) if (sum(ok) >= 3) { max_diff <- suppressWarnings(max(abs(p_vals[ok] - c_vals[ok]), na.rm = TRUE)) if (is.finite(max_diff) && max_diff < 1e-8) { is_1h_accum <- TRUE break } } } } } dt_mins <- NA_real_ if ("datetime" %in% names(df) && nrow(df) > 1) { dt_series <- df$datetime[order(df$datetime)] dt_vals <- as.numeric(difftime(dt_series[2:nrow(df)], dt_series[1:(nrow(df) - 1)], units = "mins")) dt_vals <- dt_vals[is.finite(dt_vals) & dt_vals > 0] if (length(dt_vals) > 0) { dt_mins <- suppressWarnings(stats::median(dt_vals, na.rm = TRUE)) } } updated_every_10m <- is.finite(dt_mins) && dt_mins >= 9 && dt_mins <= 11 if (is_1h_accum && updated_every_10m) { axis_title <- "Precipitation (1h accumulation,
updated every 10 min)" } } plotly::plot_ly( data = df, x = ~datetime, y = ~precip, name = paste0("Precip (", label_suffix, ")"), type = "bar", marker = list(color = "#0277bd"), hovertemplate = paste0("Precip: %{y:.1f} mm") ) %>% plotly::layout( title = list(text = paste("Precipitation:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = axis_title), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Snow Plot (Individual - fresh snow and snow depth) #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_snow_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_fresh <- "snow_fresh" %in% names(df) && any(!is.na(df$snow_fresh)) has_depth <- "snow_depth" %in% names(df) && any(!is.na(df$snow_depth)) if (!has_fresh && !has_depth) { return(create_empty_plot("No snow data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Use explicit range to preserve the full timeline time_range <- c(min(df$datetime), max(df$datetime)) # Initialize plot p <- plotly::plot_ly(x = ~datetime) # Add Fresh Snow (Left Axis, Y1) - plotted first (behind) if (has_fresh) { p <- p %>% plotly::add_bars( data = df %>% filter(!is.na(snow_fresh)), y = ~snow_fresh, name = "Fresh Snow", marker = list(color = "#B0BEC5"), hovertemplate = "Fresh: %{y:.1f} cm", yaxis = "y" ) } # Add Snow Depth (Right Axis, Y2) - plotted second (on top) with some transparency if needed if (has_depth) { p <- p %>% plotly::add_bars( data = df %>% filter(!is.na(snow_depth)), y = ~snow_depth, name = "Snow Depth", marker = list(color = "#90CAF9", opacity = 0.7), hovertemplate = "Depth: %{y:.1f} cm", yaxis = "y2" ) } # Configure Layout layout_args <- list( title = list(text = paste("Snow:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date", range = time_range), yaxis = list( title = get_axis_label(df, "snow_fresh", "Fresh Snow"), titlefont = list(color = "#000000"), tickfont = list(color = "#000000") ), # Default Y2 settings (hidden if not used, but we configure it just in case) yaxis2 = list( title = get_axis_label(df, "snow_depth", "Snow Depth"), titlefont = list(color = "#000000"), tickfont = list(color = "#000000"), overlaying = "y", side = "right" ), hovermode = "x unified", margin = list(t = 50, b = 80, l = 60, r = 60), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), barmode = "overlay" # Important for dual axis bars ) # If only one variable, adjust titles to remove confusion if (!has_fresh) { layout_args$yaxis$title <- "" layout_args$yaxis$showticklabels <- FALSE layout_args$yaxis$range <- c(0, 0.1) # Dummy range to hide } if (!has_depth) { layout_args$yaxis2$title <- "" layout_args$yaxis2$showticklabels <- FALSE layout_args$yaxis2$overlaying <- NULL # Don't overlay if not using } p <- do.call(plotly::layout, c(list(p), layout_args)) p %>% plotly::config(displaylogo = FALSE) } #' Create Pressure Plot (Individual) #' #' Display Sea Level Pressure and/or Station Pressure. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_pressure_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) # Check for variables (including the newly separated ones) has_sea <- "pressure_sea" %in% names(df) && any(!is.na(df$pressure_sea)) has_stat <- "pressure_station" %in% names(df) && any(!is.na(df$pressure_station)) # Fallback to generic 'pressure' column if specific ones are missing but generic exists # (generic usually stores PMERM/Sea Level by default logic in helpers.R) if (!has_sea && !has_stat && "pressure" %in% names(df) && any(!is.na(df$pressure))) { has_sea <- TRUE df$pressure_sea <- df$pressure } if (!has_sea && !has_stat) { return(create_empty_plot("No pressure data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) p <- plotly::plot_ly(type = "scatter", mode = "lines") if (has_sea) { p <- p %>% plotly::add_lines( data = df %>% filter(!is.na(pressure_sea)), x = ~datetime, y = ~pressure_sea, name = "Mean Sea Level Pressure", line = list(color = "#AB47BC", width = 1.5), hovertemplate = "MSL Pressure: %{y:.1f} hPa" ) } if (has_stat) { p <- p %>% plotly::add_lines( data = df %>% filter(!is.na(pressure_station)), x = ~datetime, y = ~pressure_station, name = "Station Pressure", line = list(color = "#7B1FA2", width = 1.5, dash = "dot"), hovertemplate = "Station Pressure: %{y:.1f} hPa" ) } p %>% plotly::layout( title = list(text = paste("Atmospheric Pressure:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = get_axis_label(df, "pressure_sea", "Pressure")), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Wind Time Series Plot #' #' Display Wind Speed (Average) and Wind Gusts over time. #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_wind_time_series_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_speed <- "wind_speed" %in% names(df) && any(!is.na(df$wind_speed)) has_gust <- "wind_gust" %in% names(df) && any(!is.na(df$wind_gust)) if (!has_speed && !has_gust) { return(create_empty_plot("No wind data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) p <- plotly::plot_ly(type = "scatter", mode = "lines") if (has_speed) { p <- p %>% plotly::add_lines( data = df %>% filter(!is.na(wind_speed)), x = ~datetime, y = ~wind_speed, name = "Wind Speed (Avg)", line = list(color = "#43a047", width = 1), fill = "tozeroy", hovertemplate = "Wind: %{y:.1f} m/s" ) } if (has_gust) { p <- p %>% plotly::add_markers( data = df %>% filter(!is.na(wind_gust)), x = ~datetime, y = ~wind_gust, name = "Gust (Max)", marker = list(color = "#2e7d32", size = 4), hovertemplate = "Gust: %{y:.1f} m/s" ) } p %>% plotly::layout( title = list(text = paste("Wind Speed & Gusts:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = get_axis_label(df, "wind_speed", "Speed")), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Cloud Cover Plot #' #' Display Cloud Cover in Oktas (0-8). #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_cloud_cover_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_cloud <- "cloud_cover" %in% names(df) && any(!is.na(df$cloud_cover)) if (!has_cloud) { return(create_empty_plot("No cloud cover data available")) } # Aggregate to Daily Mean # This smoothing provides a more representative view for sparse data daily_cloud <- df %>% filter(!is.na(cloud_cover)) %>% mutate(date = as.Date(datetime)) %>% group_by(date) %>% summarise( mean_cloud = mean(cloud_cover, na.rm = TRUE), n_obs = n(), .groups = "drop" ) if (nrow(daily_cloud) == 0) { return(create_empty_plot("No valid cloud cover data found")) } date_range_str <- paste( format(min(daily_cloud$date), "%d %b %Y"), "-", format(max(daily_cloud$date), "%d %b %Y") ) plotly::plot_ly( data = daily_cloud, x = ~date, y = ~mean_cloud, name = "Daily Mean Cloud Cover", type = "bar", marker = list(color = "#78909C", line = list(color = "#546E7A", width = 1)), hovertemplate = "Date: %{x}
Mean Cloud: %{y:.1f}/8
Obs: %{customdata} hours", customdata = ~n_obs ) %>% plotly::layout( title = list(text = paste("Daily Mean Cloud Cover:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = "Mean Cloud Cover (0-8)", range = c(0, 8.5), tickvals = 0:8), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Visibility Plot #' #' Display Visibility (m or km). #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_visibility_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_vis <- "visibility" %in% names(df) && any(!is.na(df$visibility)) if (!has_vis) { return(create_empty_plot("No visibility data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Convert to km for readability if max visibility is large (> 2000m) max_vis <- max(df$visibility, na.rm = TRUE) is_km <- max_vis > 2000 if (is_km) { df <- df %>% mutate(vis_display = visibility / 1000) y_lab <- "Visibility (km)" h_temp <- "Vis: %{y:.1f} km" } else { df <- df %>% mutate(vis_display = visibility) y_lab <- "Visibility (m)" h_temp <- "Vis: %{y:.0f} m" } plotly::plot_ly( data = df, x = ~datetime, y = ~vis_display, name = "Visibility", type = "scatter", mode = "lines", line = list(color = "#5D4037", width = 1.5), hovertemplate = h_temp ) %>% plotly::layout( title = list(text = paste("Visibility:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = y_lab), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Ground Temperature Plot #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_ground_temp_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_ground <- "temp_min_ground" %in% names(df) && any(!is.na(df$temp_min_ground)) has_50cm <- "temp_min_50cm" %in% names(df) && any(!is.na(df$temp_min_50cm)) if (!has_ground && !has_50cm) { return(create_empty_plot("No ground temperature data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) p <- plotly::plot_ly(type = "scatter", mode = "lines") if (has_ground) { p <- p %>% plotly::add_lines( data = df %>% filter(!is.na(temp_min_ground)), x = ~datetime, y = ~temp_min_ground, name = "Ground Surface Min (TNSOL)", line = list(color = "#388E3C", width = 1.5), hovertemplate = "Ground Min: %{y:.1f}°C" ) } if (has_50cm) { p <- p %>% plotly::add_lines( data = df %>% filter(!is.na(temp_min_50cm)), x = ~datetime, y = ~temp_min_50cm, name = "Soil Min (-50cm)", line = list(color = "#795548", width = 1.5, dash = "dot"), hovertemplate = "Soil (-50cm): %{y:.1f}°C" ) } # Add Freezing Line p <- p %>% plotly::add_lines( x = c(min(df$datetime), max(df$datetime)), y = c(0, 0), name = "Freezing (0°C)", line = list(color = "rgba(100,100,200,0.5)", width = 1, dash = "dash"), showlegend = FALSE, hoverinfo = "skip" ) p %>% plotly::layout( title = list(text = paste("Ground Temperatures:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = get_axis_label(df, "temp_min_ground", "Temperature")), hovermode = "x unified", hoverlabel = list(), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Soil Temperature Plot #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_soil_temp_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) # Check for variables vars <- c("soil_temp_10cm", "soil_temp_20cm", "soil_temp_50cm", "soil_temp_100cm") has_any <- any(sapply(vars, function(v) v %in% names(df) && any(!is.na(df[[v]])))) if (!has_any) { return(create_empty_plot("No soil temperature data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) p <- plotly::plot_ly(type = "scatter", mode = "lines") # Colors for depths: Lighter to Darker Brown colors <- c( "soil_temp_10cm" = "#D7CCC8", "soil_temp_20cm" = "#A1887F", "soil_temp_50cm" = "#795548", "soil_temp_100cm" = "#3E2723" ) names <- c( "soil_temp_10cm" = "Soil -10cm", "soil_temp_20cm" = "Soil -20cm", "soil_temp_50cm" = "Soil -50cm", "soil_temp_100cm" = "Soil -100cm" ) for (v in vars) { if (v %in% names(df) && any(!is.na(df[[v]]))) { p <- p %>% plotly::add_lines( data = df %>% filter(!is.na(.data[[v]])), x = ~datetime, y = as.formula(paste0("~", v)), name = names[[v]], line = list(color = colors[[v]], width = 1.5), hovertemplate = paste0(names[[v]], ": %{y:.1f}°C") ) } } # Add Freezing Line p <- p %>% plotly::add_lines( x = c(min(df$datetime), max(df$datetime)), y = c(0, 0), name = "Freezing (0°C)", line = list(color = "rgba(100,100,200,0.5)", width = 1, dash = "dash"), showlegend = FALSE, hoverinfo = "skip" ) p %>% plotly::layout( title = list(text = paste("Soil Temperatures:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = get_axis_label(df, "soil_temp_10cm", "Temperature")), hovermode = "x unified", margin = list(t = 50, b = 80, l = 60, r = 20), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) } #' Create Wind 2m and Instant Gust Plot #' #' @param df Data frame with parsed weather data #' @return A plotly object #' @export create_wind_2m_gust_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available")) } df <- clean_weather_data(df) has_FF2 <- "wind_speed_2m" %in% names(df) && any(!is.na(df$wind_speed_2m)) has_FXI <- "wind_gust_inst" %in% names(df) && any(!is.na(df$wind_gust_inst)) if (!has_FF2 && !has_FXI) { return(create_empty_plot("No Wind 2m / Instant Gust data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) p <- plotly::plot_ly(type = "scatter", mode = "lines") if (has_FF2) { p <- p %>% plotly::add_lines( data = df %>% filter(!is.na(wind_speed_2m)), x = ~datetime, y = ~wind_speed_2m, name = "Wind Speed (2m)", line = list(color = "#81C784", width = 1.5), # Light Green hovertemplate = "Wind (2m): %{y:.1f} m/s" ) } if (has_FXI) { p <- p %>% plotly::add_markers( data = df %>% filter(!is.na(wind_gust_inst)), x = ~datetime, y = ~wind_gust_inst, name = "Instant Gust (max)", marker = list(color = "#D32F2F", size = 3, opacity = 0.6), # Red dots hovertemplate = "Inst. Gust: %{y:.1f} m/s" ) } p %>% plotly::layout( title = list(text = paste("Wind (2m) & Instant Gusts:", date_range_str), font = list(size = 14)), xaxis = list(title = "", type = "date"), yaxis = list(title = get_axis_label(df, "wind_speed_2m", "Speed")), hovermode = "x unified", margin = list(t = 50, b = 80, l = 60, r = 20), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.25), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE) }