#' Weather Plotting Functions for SSOD Data #' #' Functions to create weather visualizations using plotly. #' 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, scrollZoom = FALSE) } #' Insert NA Rows at Temporal Gaps to Break Lines/Areas #' #' Detects gaps in a time series and inserts rows with NA values #' at those gaps. This ensures plotly lines/areas are visually broken #' where data is missing, avoiding false representations. #' #' @param df Data frame with a datetime column sorted by time #' @param value_cols Character vector of column names containing values to plot. #' NA rows will be inserted for these columns at gap positions. #' @param gap_threshold_multiplier Numeric. A gap is detected when the time #' interval between consecutive points is greater than #' (median_interval * gap_threshold_multiplier). Default is 2. #' @return Data frame with NA rows inserted at gaps #' @keywords internal insert_gap_breaks <- function(df, value_cols, gap_threshold_multiplier = 2, min_gap_threshold = NULL) { if (is.null(df) || nrow(df) < 2) { return(df) } # Ensure sorted by datetime df <- df %>% dplyr::arrange(datetime) # Calculate time intervals between consecutive observations time_diffs <- diff(as.numeric(df$datetime)) if (length(time_diffs) == 0 || all(is.na(time_diffs))) { return(df) } # Use median interval to determine expected sampling rate median_interval <- stats::median(time_diffs, na.rm = TRUE) # Determine gap threshold gap_threshold <- 0 if (!is.null(min_gap_threshold)) { gap_threshold <- min_gap_threshold } else { if (is.na(median_interval) || median_interval <= 0) { return(df) } gap_threshold <- median_interval * gap_threshold_multiplier } gap_indices <- which(time_diffs > gap_threshold) if (length(gap_indices) == 0) { return(df) # No gaps detected } # Create NA rows to insert at gap positions # Place them just after the gap starts (midpoint for datetime) gap_rows <- lapply(gap_indices, function(i) { row <- df[i, , drop = FALSE] # Set datetime to midpoint of the gap row$datetime <- df$datetime[i] + (df$datetime[i + 1] - df$datetime[i]) / 2 # Set all value columns to NA for (col in value_cols) { if (col %in% names(row)) { row[[col]] <- NA } } row }) # Combine with original data and re-sort gap_df <- dplyr::bind_rows(gap_rows) result <- dplyr::bind_rows(df, gap_df) %>% dplyr::arrange(datetime) return(result) } #' Split Data into Continuous Chunks at Temporal Gaps #' #' Detects gaps in a time series and splits the data into separate #' data frames for each continuous segment. This is necessary for #' plotly filled areas where NA values alone don't break the fill. #' #' @param df Data frame with a datetime column sorted by time #' @param value_col Character. Column name to check for NA (only split on non-NA data) #' @param gap_threshold_multiplier Numeric. A gap is detected when the time #' interval between consecutive points is greater than #' (median_interval * gap_threshold_multiplier). Default is 2. #' @return A list of data frames, each representing a continuous chunk #' @keywords internal split_into_chunks <- function(df, value_col, gap_threshold_multiplier = 2, min_gap_threshold = NULL) { if (is.null(df) || nrow(df) < 2) { return(list(df)) } # Filter to non-NA values for the specified column and sort df <- df %>% dplyr::filter(!is.na(!!rlang::sym(value_col))) %>% dplyr::arrange(datetime) if (nrow(df) < 2) { return(list(df)) } # Calculate time intervals between consecutive observations time_diffs <- diff(as.numeric(df$datetime)) if (length(time_diffs) == 0 || all(is.na(time_diffs))) { return(list(df)) } # Use median interval to determine expected sampling rate median_interval <- stats::median(time_diffs, na.rm = TRUE) # Determine gap threshold gap_threshold <- 0 if (!is.null(min_gap_threshold)) { gap_threshold <- min_gap_threshold } else { if (is.na(median_interval) || median_interval <= 0) { return(list(df)) } gap_threshold <- median_interval * gap_threshold_multiplier } gap_indices <- which(time_diffs > gap_threshold) if (length(gap_indices) == 0) { return(list(df)) # No gaps, return as single chunk } # Split into chunks at gap positions chunks <- list() start_idx <- 1 for (gap_idx in gap_indices) { chunks[[length(chunks) + 1]] <- df[start_idx:gap_idx, , drop = FALSE] start_idx <- gap_idx + 1 } # Add the final chunk if (start_idx <= nrow(df)) { chunks[[length(chunks) + 1]] <- df[start_idx:nrow(df), , drop = FALSE] } return(chunks) } #' Create Temperature Plot (Single Panel) #' #' Creates a standalone temperature plot. #' #' @param df Data frame with parsed SSOD data #' @return A plotly object #' @export create_temperature_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Temperature")) } 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)) if (!has_temp && !has_tmin && !has_tmax) { return(create_empty_plot("No data available for Temperature")) } p <- plotly::plot_ly(type = "scatter", mode = "lines") if (has_tmin && has_tmax) { df_ribbon <- df %>% dplyr::filter(!is.na(temp_min) & !is.na(temp_max)) chunks <- split_into_chunks(df_ribbon, "temp_min", min_gap_threshold = 86400) for (i in seq_along(chunks)) { chunk <- chunks[[i]] if (nrow(chunk) < 1) next show_leg <- (i == 1) 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" ) 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 { if (has_tmax) { df_tmax <- insert_gap_breaks(df, value_cols = c("temp_max"), min_gap_threshold = 86400) p <- p %>% plotly::add_lines( data = df_tmax, x = ~datetime, y = ~temp_max, name = "Tmax", line = list(color = "#d32f2f", width = 2), hovertemplate = "Tmax: %{y:.1f}°C", connectgaps = FALSE, showlegend = TRUE ) } if (has_tmin) { df_tmin <- insert_gap_breaks(df, value_cols = c("temp_min"), min_gap_threshold = 86400) p <- p %>% plotly::add_lines( data = df_tmin, x = ~datetime, y = ~temp_min, name = "Tmin", line = list(color = "#1976d2", width = 2), hovertemplate = "Tmin: %{y:.1f}°C", connectgaps = FALSE, showlegend = TRUE ) } } if (has_temp) { df_temp <- insert_gap_breaks(df, value_cols = c("temp"), min_gap_threshold = 86400) p <- p %>% plotly::add_lines( data = df_temp, x = ~datetime, y = ~temp, name = if (has_tmin || has_tmax) "Tmean" else "Temperature", line = list(color = if (has_tmin || has_tmax) "#b71c1c" else "#e53935", width = 2), connectgaps = FALSE, hovertemplate = paste0(if (has_tmin || has_tmax) "Tmean" else "Temp", ": %{y:.1f}°C"), showlegend = if (has_tmin || has_tmax) TRUE else FALSE ) } p %>% plotly::layout( title = list(text = paste("Temperature:", date_range_str), font = list(size = 14)), yaxis = list(title = "Temperature (°C)"), xaxis = list(title = "", type = "date"), hovermode = "x unified", legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 50, l = 50, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE, scrollZoom = FALSE) } #' Create Humidity Plot (Single Panel) #' #' Creates a standalone plot for Dew Point and Relative Humidity. #' #' @param df Data frame with parsed SSOD data #' @return A plotly object #' @export create_humidity_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Humidity")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) has_rh <- "rh" %in% names(df) && any(!is.na(df$rh)) has_dew <- "dew_point" %in% names(df) && any(!is.na(df$dew_point)) if (!has_rh && !has_dew) { return(create_empty_plot("No data available for Humidity")) } # Insert NA rows at temporal gaps to break lines value_cols <- c() if (has_dew) value_cols <- c(value_cols, "dew_point") if (has_rh) value_cols <- c(value_cols, "rh") df_plot <- insert_gap_breaks(df, value_cols = value_cols, min_gap_threshold = 86400) p <- plotly::plot_ly(df_plot, x = ~datetime) if (has_dew) { p <- p %>% plotly::add_lines( y = ~dew_point, name = "Dew Point", line = list(color = "#1e88e5", width = 1.5), connectgaps = FALSE, hovertemplate = "Dew Pt: %{y:.1f}°C" ) } if (has_rh) { p <- p %>% plotly::add_lines( y = ~rh, name = "RH%", line = list(color = "#43a047", width = 1.5, dash = "dot"), yaxis = "y2", connectgaps = FALSE, hovertemplate = "RH: %{y:.0f}%" ) } 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", legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 50, 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, scrollZoom = FALSE) } #' Create Wind Overview Plot (Single Panel) #' #' Creates a standalone wind speed and gusts plot. #' #' @param df Data frame with parsed SSOD data #' @return A plotly object #' @export create_wind_overview_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No data available for Wind")) } 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" %in% names(df) && any(!is.na(df$wind_gust)) if (!has_wind && !has_gust) { return(create_empty_plot("No data available for Wind")) } p <- plotly::plot_ly() # For filled areas, we need to split into chunks and plot each as separate trace # This is the only way to properly break the fill at temporal gaps in plotly if (has_wind) { wind_chunks <- split_into_chunks(df, value_col = "wind_speed", min_gap_threshold = 86400) for (i in seq_along(wind_chunks)) { chunk <- wind_chunks[[i]] if (nrow(chunk) > 0) { p <- p %>% plotly::add_lines( data = chunk, x = ~datetime, y = ~wind_speed, name = if (i == 1) "Wind Speed" else NULL, legendgroup = "wind", showlegend = (i == 1), fill = "tozeroy", fillcolor = "rgba(67, 160, 71, 0.3)", line = list(color = "#43a047", width = 1), connectgaps = FALSE, hovertemplate = "Wind: %{y:.1f} m/s" ) } } } # Gusts are markers, so gap handling is not needed if (has_gust) { gust_data <- df %>% dplyr::filter(!is.na(wind_gust)) if (nrow(gust_data) > 0) { p <- p %>% plotly::add_markers( data = gust_data, x = ~datetime, y = ~wind_gust, name = "Gust", marker = list(color = "#2e7d32", size = 4), hovertemplate = "Gust: %{y:.1f} m/s" ) } } p %>% plotly::layout( title = list(text = paste("Wind:", date_range_str), font = list(size = 14)), yaxis = list(title = "Wind (m/s)"), xaxis = list(title = "", type = "date"), hovermode = "x unified", legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 50, l = 50, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE, scrollZoom = FALSE) } #' Create Pressure Plot (Single Panel) #' #' Creates a standalone pressure plot. #' #' @param df Data frame with parsed SSOD 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 for Pressure")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) has_msl <- "pressure" %in% names(df) && any(!is.na(df$pressure)) has_stn <- "station_pressure" %in% names(df) && any(!is.na(df$station_pressure)) if (!has_msl && !has_stn) { return(create_empty_plot("No data available for Pressure")) } # Insert NA rows at temporal gaps to break lines value_cols <- c() if (has_msl) value_cols <- c(value_cols, "pressure") if (has_stn) value_cols <- c(value_cols, "station_pressure") df_plot <- insert_gap_breaks(df, value_cols = value_cols, min_gap_threshold = 86400) p <- plotly::plot_ly(df_plot, x = ~datetime) if (has_msl) { p <- p %>% plotly::add_lines( y = ~pressure, name = "MSL Pressure", line = list(color = "#7b1fa2", width = 1.5), connectgaps = FALSE, hovertemplate = "MSL: %{y:.1f} hPa" ) } if (has_stn) { p <- p %>% plotly::add_lines( y = ~station_pressure, name = "Stn Pressure", line = list(color = "#ef6c00", width = 1.2, dash = "dot"), connectgaps = FALSE, hovertemplate = "Stn: %{y:.1f} hPa" ) } p %>% plotly::add_segments( x = min(df$datetime), xend = max(df$datetime), y = 1013.25, yend = 1013.25, name = "Standard (1013.25)", line = list(color = "rgba(100, 100, 100, 0.5)", width = 1, dash = "dash"), showlegend = TRUE ) %>% plotly::layout( title = list(text = paste("Pressure:", date_range_str), font = list(size = 14)), yaxis = list(title = "Pressure (hPa)"), xaxis = list(title = "", type = "date"), hovermode = "x unified", legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1), margin = list(t = 50, b = 50, l = 50, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE, scrollZoom = FALSE) } #' Create Visibility Plot (Single Panel) #' #' Creates a standalone visibility plot. #' #' @param df Data frame with parsed SSOD 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 for Visibility")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) if (!"vis" %in% names(df) || all(is.na(df$vis))) { return(create_empty_plot("No data available for Visibility")) } # Insert NA rows at temporal gaps to break lines df_plot <- insert_gap_breaks(df, value_cols = c("vis"), min_gap_threshold = 86400) plotly::plot_ly(df_plot, x = ~datetime) %>% plotly::add_lines( y = ~vis, name = "Visibility", line = list(color = "#607d8b", width = 1.5), connectgaps = FALSE, hovertemplate = "Vis: %{y:.1f} km" ) %>% plotly::layout( title = list(text = paste("Visibility:", date_range_str), font = list(size = 14)), yaxis = list(title = "Vis (km)"), xaxis = list(title = "", type = "date"), hovermode = "x unified", margin = list(t = 50, b = 50, l = 50, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE, scrollZoom = FALSE) } #' Create Precipitation Plot #' #' Creates a stacked subplot for all available precipitation intervals. #' #' @param df Data frame with parsed SSOD data #' @return A plotly subplot object #' @export create_precipitation_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { 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") ) precip_cols <- c( "precip" = "1h", "precip_3h" = "3h", "precip_6h" = "6h", "precip_9h" = "9h", "precip_12h" = "12h", "precip_15h" = "15h", "precip_18h" = "18h", "precip_21h" = "21h", "precip_24h" = "24h" ) plot_list <- list() for (col_name in names(precip_cols)) { if (col_name %in% names(df) && any(!is.na(df[[col_name]]))) { label <- precip_cols[[col_name]] p_data <- df %>% dplyr::filter(!is.na(!!rlang::sym(col_name))) %>% dplyr::select(datetime, val = !!rlang::sym(col_name)) if (nrow(p_data) > 0) { p <- plotly::plot_ly(p_data, x = ~datetime) %>% plotly::add_bars( y = ~val, name = paste("Precip", label), marker = list(color = "#0277bd"), hovertemplate = paste0(label, ": %{y:.1f} mm"), showlegend = FALSE ) %>% 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")) } 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", margin = list(t = 50, b = 80, l = 60, r = 20), modebar = list(orientation = "h") ) %>% plotly::config(displaylogo = FALSE, scrollZoom = FALSE) } #' Create Wind Rose Plot #' #' Creates a polar bar chart showing wind direction and speed distribution. #' #' @param df Data frame with parsed SSOD 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 wind data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) if (!all(c("wind_speed", "wind_dir") %in% names(df))) { return(create_empty_plot("No wind data available")) } wind_df <- df %>% dplyr::filter(!is.na(!!rlang::sym("wind_speed")), !is.na(!!rlang::sym("wind_dir"))) if (nrow(wind_df) == 0) { return(create_empty_plot("No wind data available")) } wind_df <- wind_df %>% dplyr::mutate( dir_bin = round(!!rlang::sym("wind_dir") / 22.5) * 22.5, dir_bin = ifelse(dir_bin == 360, 0, dir_bin), speed_cat = cut( !!rlang::sym("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(!!rlang::sym("dir_bin"), !!rlang::sym("speed_cat")) %>% dplyr::summarise(count = dplyr::n(), .groups = "drop") total_obs <- sum(wind_df$count) wind_df <- wind_df %>% dplyr::mutate(percentage = !!rlang::sym("count") / total_obs * 100) 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() 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(!!rlang::sym("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 = 10) ), 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.1, xref = "paper", yref = "paper", text = "ⓘ", showarrow = FALSE, font = list(size = 18, color = "#666"), xanchor = "left", hovertext = "Wind rose shows the frequency distribution of wind direction and speed", hoverinfo = "text", name = "info_annotation" ) ) ) %>% plotly::config(displaylogo = FALSE, scrollZoom = 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 SSOD data #' @return A plotly heatmap #' @export create_weathergami_plot <- function(df) { if (is.null(df) || nrow(df) == 0) { return(create_empty_plot("No temperature data available")) } # Use df directly since SSOD data is already daily daily_summary <- df if (is.null(daily_summary) || nrow(daily_summary) == 0) { return(create_empty_plot("No temperature data available")) } # Filter to days with valid temp_max and temp_min daily_temps <- daily_summary %>% dplyr::filter(!is.na(!!rlang::sym("temp_max")), !is.na(!!rlang::sym("temp_min"))) if (nrow(daily_temps) == 0) { return(create_empty_plot("No temperature data available")) } date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) # Round to nearest integer for binning daily_temps <- daily_temps %>% dplyr::mutate( tmax_bin = round(!!rlang::sym("temp_max")), tmin_bin = round(!!rlang::sym("temp_min")) ) # Count occurrences of each max/min combination freq_table <- daily_temps %>% dplyr::group_by(!!rlang::sym("tmax_bin"), !!rlang::sym("tmin_bin")) %>% dplyr::summarise(count = dplyr::n(), .groups = "drop") tmin_range <- range(freq_table$tmin_bin) tmax_range <- range(freq_table$tmax_bin) p <- plotly::plot_ly() %>% 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}" ) ) diag_range <- seq( min(tmin_range[1], tmax_range[1]) - 2, max(tmin_range[2], tmax_range[2]) + 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 ), showlegend = TRUE, legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.15), margin = list(t = 50, b = 100, l = 60, r = 20), modebar = list(orientation = "h"), annotations = list( list( x = 0, y = 1.12, xref = "paper", yref = "paper", text = "ⓘ", showarrow = FALSE, font = list(size = 18, color = "#666"), xanchor = "left", hovertext = paste( "Weathergami shows how often each combination of", "daily max and min temperatures occurs.
", "Darker colors indicate more frequent combinations." ), hoverinfo = "text", name = "info_annotation" ) ) ) %>% plotly::config(displaylogo = FALSE, scrollZoom = FALSE) } #' Create Diurnal Temperature Cycle Plot #' #' Creates a plot showing temperature variation by hour of day. #' #' @param df Data frame with parsed SSOD 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 temperature data available")) } df_diurnal <- df %>% dplyr::filter(!is.na(!!rlang::sym("temp"))) %>% dplyr::mutate( local_time = !!rlang::sym("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")) } median_cycle <- df_diurnal %>% dplyr::group_by(!!rlang::sym("hour_val")) %>% dplyr::summarise(median_temp = median(!!rlang::sym("temp"), na.rm = TRUE), .groups = "drop") date_range_str <- paste( format(min(df$datetime), "%d %b %Y"), "-", format(max(df$datetime), "%d %b %Y") ) unique_dates <- sort(unique(df_diurnal$date)) sampled_dates <- unique_dates[seq(1, length(unique_dates), by = 5)] df_diurnal_sampled <- df_diurnal %>% dplyr::filter(!!rlang::sym("date") %in% sampled_dates) tz_sign <- ifelse(offset_hours >= 0, "+", "") tz_str <- paste0("UTC", tz_sign, round(offset_hours, 1)) plotly::plot_ly() %>% plotly::add_lines( data = df_diurnal_sampled, x = ~hour_val, y = ~temp, split = ~date, line = list(color = "rgba(150, 150, 150, 0.3)", width = 0.5), connectgaps = FALSE, hoverinfo = "none", showlegend = FALSE, name = "Daily Cycles" ) %>% plotly::add_lines( data = median_cycle %>% dplyr::filter(!is.na(!!rlang::sym("median_temp"))), x = ~hour_val, y = ~median_temp, name = "Median Cycle", line = list(color = "#d32f2f", width = 4), connectgaps = FALSE, hovertemplate = "Hour: %{x}:00
Median Temp: %{y:.1f}°C" ) %>% plotly::layout( title = list(text = paste("Diurnal Temperature Cycle:", date_range_str), font = list(size = 14)), xaxis = list(title = paste0("Hour of Day (Local Mean Time, ", tz_str, ")"), tickvals = seq(0, 23, by = 3)), yaxis = list(title = "Temperature (°C)"), showlegend = TRUE, 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"), annotations = list( list( x = 0, y = 1.12, xref = "paper", yref = "paper", text = "ⓘ", showarrow = FALSE, font = list(size = 18, color = "#666"), xanchor = "left", hovertext = paste( "Diurnal Temperature Cycle
", "Shows daily temperature patterns.

", "Grey lines: Individual days (sampled)
", "Red line: Median hourly temperature

", "Time Zone: Local Mean Time (LMT)
", "Approximated from longitude:
", "UTC + (Longitude / 15)" ), hoverinfo = "text", name = "info_annotation" ) ) ) %>% plotly::config(displaylogo = FALSE, scrollZoom = FALSE) }