#' 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)
}