meteogate / funs /helpers_plotting.R
alexdum's picture
Sync current state to Hugging Face
8cc5e12
# 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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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}%<extra></extra>",
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<extra></extra>"),
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 = "&#9432;", # 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.<br>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<br>",
"Tmin: %{y}°C<br>",
"Days: %{z}<extra></extra>"
)
)
# 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<br>Median: %{y:.1f}°C<extra></extra>",
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²<extra></extra>"
) %>%
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<extra></extra>"
y_range <- NULL # Auto
} else {
plot_data <- plot_data %>% mutate(sun_display = sunshine_duration)
y_title <- "Sun (min)"
hv_temp <- "Sunshine: %{y:.0f} min<extra></extra>"
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<extra></extra>"
) %>%
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>",
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<extra></extra>"),
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<extra></extra>",
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}%<extra></extra>",
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²<extra></extra>"
) %>%
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<extra></extra>"
y_range <- NULL
} else {
df <- df %>% mutate(sun_display = sunshine_duration)
y_title <- "Sunshine (min)"
hv_temp <- "Sunshine: %{y:.0f} min<extra></extra>"
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<extra></extra>"
) %>%
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,<br>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<extra></extra>")
) %>%
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<extra></extra>",
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<extra></extra>",
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<extra></extra>"
)
}
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<extra></extra>"
)
}
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<extra></extra>"
)
}
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<extra></extra>"
)
}
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}<br>Mean Cloud: %{y:.1f}/8<br>Obs: %{customdata} hours<extra></extra>",
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<extra></extra>"
} else {
df <- df %>% mutate(vis_display = visibility)
y_lab <- "Visibility (m)"
h_temp <- "Vis: %{y:.0f} m<extra></extra>"
}
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<extra></extra>"
)
}
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<extra></extra>"
)
}
# 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<extra></extra>")
)
}
}
# 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<extra></extra>"
)
}
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<extra></extra>"
)
}
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)
}