jma / server.R
alexdum's picture
refactor: explicitly qualify the `JS` function call with `DT::`.
b3cdfbe
source("funs/scraper.R")
source("funs/map_helpers.R")
source("funs/plot_weather_jma.R")
server <- function(input, output, session) {
selected_station <- reactiveVal(NULL)
station_data <- reactiveVal(NULL) # Store loaded data for display
prev_dates <- reactiveVal(NULL) # Track previous date inputs for specific difference detection
url_initialized <- reactiveVal(FALSE) # Track if URL params have been applied
# MapLibre Reactive Values
style_change_trigger <- reactiveVal(0) # Trigger station re-render after style change
map_initialized <- reactiveVal(FALSE) # Track if map bounds have been set
stations_before_id <- reactiveVal(NULL) # Layer ID for station ordering
current_raster_layers <- reactiveVal(character(0)) # Track dynamically added raster layers
# Helper: Parse URL query string
parse_url_params <- function(query_string) {
if (is.null(query_string) || query_string == "" || query_string == "?") {
return(list())
}
# Remove leading "?"
qs <- sub("^\\?", "", query_string)
if (qs == "") {
return(list())
}
pairs <- strsplit(qs, "&")[[1]]
params <- list()
for (pair in pairs) {
kv <- strsplit(pair, "=")[[1]]
if (length(kv) == 2) {
params[[kv[1]]] <- URLdecode(kv[2])
}
}
params
}
# Helper: Broadcast current state to parent page
broadcast_state <- function(view_override = NULL) {
st <- selected_station()
# Extract scalar values from tibble row
station_id <- if (!is.null(st) && nrow(st) > 0) as.character(st$ID[[1]]) else NULL
station_name <- if (!is.null(st) && nrow(st) > 0) as.character(st$DisplayName[[1]]) else NULL
prefecture <- if (!is.null(st) && nrow(st) > 0 && !is.na(st$PrecName[[1]])) as.character(st$PrecName[[1]]) else NULL
resolution <- input$data_resolution
# Determine current view
main_tab <- input$main_nav
view <- if (!is.null(view_override)) {
view_override
} else if (!is.null(main_tab)) {
if (main_tab == "Map View") {
"map"
} else if (main_tab == "Station Info") {
"station-info"
} else if (main_tab == "Dashboard") {
subtab <- input$dashboard_subtabs
if (!is.null(subtab) && subtab == "Data") {
"dashboard-data"
} else {
"dashboard-plots"
}
} else {
"map"
}
} else {
"map"
}
start_date <- if (!is.null(input$date_range)) as.character(input$date_range[1]) else NULL
end_date <- if (!is.null(input$date_range)) as.character(input$date_range[2]) else NULL
session$sendCustomMessage("updateParentURL", list(
station = station_id,
stationName = station_name,
prefecture = prefecture,
resolution = resolution,
view = view,
start = start_date,
end = end_date
))
}
# Observer: Apply URL params on app startup
observe({
req(!url_initialized())
query <- session$clientData$url_search
params <- parse_url_params(query)
if (length(params) == 0) {
url_initialized(TRUE)
return()
}
# Apply resolution
if (!is.null(params$resolution) && params$resolution %in% c("10 Minutes", "Hourly", "Daily", "Monthly")) {
updateRadioButtons(session, "data_resolution", selected = params$resolution)
}
# Apply date range
if (!is.null(params$start) && !is.null(params$end)) {
start_date <- tryCatch(as.Date(params$start), error = function(e) NULL)
end_date <- tryCatch(as.Date(params$end), error = function(e) NULL)
if (!is.null(start_date) && !is.null(end_date)) {
updateDateRangeInput(session, "date_range", start = start_date, end = end_date)
prev_dates(c(start_date, end_date))
}
}
# Apply station selection (deferred to allow inputs to update)
if (!is.null(params$station)) {
station_id <- params$station
# Find station by ID or DisplayName
st <- stations %>%
filter(ID == station_id | DisplayName == station_id) %>%
head(1)
if (nrow(st) > 0) {
selected_station(st)
updateSelectizeInput(session, "station_selector", selected = st$DisplayName)
# Trigger download after a short delay to let other inputs settle
shinyjs::delay(500, {
start_download(st)
})
}
}
# Apply view/tab navigation (deferred)
if (!is.null(params$view)) {
view <- params$view
shinyjs::delay(600, {
if (view == "map") {
session$sendCustomMessage("switchTab", list(tabId = "Map View"))
} else if (view == "station-info") {
session$sendCustomMessage("switchTab", list(tabId = "Station Info"))
} else if (view %in% c("dashboard-plots", "dashboard-data")) {
session$sendCustomMessage("switchTab", list(tabId = "Dashboard"))
if (view == "dashboard-data") {
shinyjs::delay(800, {
# Click the tab directly; assumes id="dashboard_subtabs" -> li a[data-value="Data"]
# Use robust JS to switch tab as updateNavsetCardPill might fail during init
shinyjs::runjs("$('a[data-value=\"Data\"]').tab('show');")
})
}
}
})
}
url_initialized(TRUE)
})
# helper for rich label similar to DWD
generate_label <- function(name, id, type, start, end, vars) {
paste0(
"<div style='font-size:14px; min-width: 200px;'>",
"<b>", htmltools::htmlEscape(name), "</b> (", id, ")<br>",
"Type: ", type, "<br>",
"Period: ", start, " - ", end, "<br>",
"<div style='margin-top:5px; font-size:12px; color:#555;'>",
"Vars: ", substring(vars, 1, 50), "...",
"</div>",
"</div>"
)
}
add_resolution_period <- function(df, resolution) {
df %>%
mutate(
ResolutionStart = case_when(
resolution == "Daily" ~ Start_Daily,
resolution == "Hourly" ~ Start_Hourly,
resolution == "10 Minutes" ~ Start_10min,
resolution == "Monthly" ~ Start_Monthly,
TRUE ~ NA_real_
),
ResolutionEnd = case_when(
resolution == "Daily" ~ End_Daily,
resolution == "Hourly" ~ End_Hourly,
resolution == "10 Minutes" ~ End_10min,
resolution == "Monthly" ~ End_Monthly,
TRUE ~ NA_real_
)
)
}
get_resolution_period <- function(st, resolution) {
start_val <- switch(resolution,
"Daily" = st$Start_Daily,
"Hourly" = st$Start_Hourly,
"10 Minutes" = st$Start_10min,
"Monthly" = st$Start_Monthly,
NA_real_
)
end_val <- switch(resolution,
"Daily" = st$End_Daily,
"Hourly" = st$End_Hourly,
"10 Minutes" = st$End_10min,
"Monthly" = st$End_Monthly,
NA_real_
)
list(start = start_val, end = end_val)
}
format_period_labels <- function(start, end, current_year = NULL) {
start_label <- ifelse(is.na(start), "Unknown", start)
if (is.null(current_year)) {
end_label <- ifelse(is.na(end), "Present", end)
} else {
end_label <- ifelse(is.na(end) | end == current_year, "Present", end)
}
list(start = start_label, end = end_label)
}
filtered_stations <- reactive({
res <- stations
# 1. Filter by Name if selected
# 1. Filter by Name if selected - REMOVED to prevent loop and allow map context
# if (input$station_selector != "All") {
# res <- res %>% filter(DisplayName == input$station_selector)
# }
# 2. Filter by Resolution and Date Range
if (!is.null(input$date_range) && !is.null(input$data_resolution)) {
start_yr <- as.numeric(format(input$date_range[1], "%Y"))
end_yr <- as.numeric(format(input$date_range[2], "%Y"))
resolution <- input$data_resolution
# Get the start year for the selected resolution
res <- res %>%
add_resolution_period(resolution) %>%
# Filter: Station must have data for this resolution AND overlap with date range
filter(
!is.na(ResolutionStart) & # Must have data for this resolution
ResolutionStart <= end_yr & # Started before or in the end year
(is.na(ResolutionEnd) | ResolutionEnd >= start_yr) # Still active or ended after start
) %>%
select(-ResolutionStart, -ResolutionEnd)
}
res
})
output$visible_count <- renderText({
count <- nrow(filtered_stations())
sprintf("Visible Stations: %d", count)
})
# Server-side selectize for performance
observe({
# Update choices based on filtered stations OR all stations?
# DWD uses all stations usually, but if we filter by resolution, maybe strictly filtered?
# Let's use filtered for consistency with the map.
df <- filtered_stations()
# We need a named vector or just names.
# choices are DisplayName, values are DisplayName (or ID? existing logic uses DisplayName)
# Existing logic uses DisplayName for lookup.
updateSelectizeInput(session, "station_selector",
choices = c("All", sort(df$DisplayName)),
server = TRUE
)
})
output$map <- renderMaplibre({
maplibre(
style = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
center = c(138, 36),
zoom = 4
) %>%
add_navigation_control(show_compass = FALSE, visualize_pitch = FALSE, position = "top-left")
})
# Initialize map bounds only once
observe({
req(!map_initialized())
req(input$map_zoom) # Wait for map to be ready
# Initial bounds set by renderMaplibre, just mark initialized
map_initialized(TRUE)
})
# Zoom to extent of all filtered stations
observeEvent(input$zoom_home, {
df <- filtered_stations()
req(df)
if (nrow(df) > 0) {
# Calculate bounds
lons <- range(df$Lon, na.rm = TRUE)
lats <- range(df$Lat, na.rm = TRUE)
# If only one station, zoom to it with a small buffer
if (nrow(df) == 1) {
maplibre_proxy("map") %>%
fly_to(center = c(df$Lon[1], df$Lat[1]), zoom = 12)
} else {
maplibre_proxy("map") %>%
fit_bounds(c(lons[1], lats[1], lons[2], lats[2]), animate = TRUE)
}
}
})
# Fix Map Rendering on Tab Switch
observeEvent(input$main_nav, {
if (input$main_nav == "Map View") {
# Slight delay to ensure tab is visible - MapLibre handles resize automatically
shinyjs::runjs("
setTimeout(function() {
var map = document.getElementById('map');
if (map && map.__mapgl) {
map.__mapgl.resize();
}
}, 200);
")
}
})
# --- Basemap Switching ---
observeEvent(input$basemap, {
proxy <- maplibre_proxy("map")
# Explicitly remove any previously added raster layers
old_layers <- isolate(current_raster_layers())
if (length(old_layers) > 0) {
for (layer_id in old_layers) {
proxy %>% clear_layer(layer_id)
}
current_raster_layers(character(0))
}
if (input$basemap %in% c("carto_positron", "carto_voyager")) {
# VECTOR LOGIC (Carto-based styles)
style_url <- switch(input$basemap,
"carto_positron" = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
"carto_voyager" = "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
)
proxy %>%
set_style(style_url)
# For vector sandwich, we want stations below labels
stations_before_id("watername_ocean")
style_change_trigger(isolate(style_change_trigger()) + 1)
} else if (input$basemap == "esri_imagery") {
# Esri Imagery (Raster + Styles?) - DWD implementation uses Voyager style base + Raster layer
proxy %>%
set_style("https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json")
stations_before_id("watername_ocean")
current_session <- shiny::getDefaultReactiveDomain()
selected_basemap <- input$basemap
later::later(function() {
shiny::withReactiveDomain(current_session, {
# Race condition check
current_basemap <- isolate(input$basemap)
if (current_basemap != selected_basemap) {
return()
}
unique_suffix <- as.numeric(Sys.time()) * 1000
source_id <- paste0("esri_imagery_source_", unique_suffix)
layer_id <- paste0("esri_imagery_layer_", unique_suffix)
esri_url <- "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
maplibre_proxy("map") %>%
add_raster_source(id = source_id, tiles = c(esri_url), tileSize = 256) %>%
add_layer(
id = layer_id,
type = "raster",
source = source_id,
paint = list("raster-opacity" = 1),
before_id = "watername_ocean"
)
current_raster_layers(c(layer_id))
style_change_trigger(isolate(style_change_trigger()) + 1)
})
}, delay = 0.5)
} else {
# RASTER LOGIC (Esri Topo, OSM)
tile_url <- if (input$basemap %in% c("osm", "osm_gray")) {
"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
} else {
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"
}
attribution_text <- if (input$basemap %in% c("osm", "osm_gray")) {
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
} else {
"Tiles &copy; Esri"
}
paint_props <- list("raster-opacity" = 1)
if (input$basemap == "osm_gray") {
paint_props[["raster-saturation"]] <- -0.9
paint_props[["raster-contrast"]] <- 0.3
}
# Use blank style + raster layer
blank_style <- list(
version = 8,
sources = list(),
layers = list(),
metadata = list(timestamp = as.numeric(Sys.time()))
)
json_blank <- jsonlite::toJSON(blank_style, auto_unbox = TRUE)
blank_uri <- paste0("data:application/json,", URLencode(as.character(json_blank), reserved = TRUE))
proxy %>%
set_style(blank_uri)
current_session <- shiny::getDefaultReactiveDomain()
selected_basemap <- input$basemap
later::later(function() {
shiny::withReactiveDomain(current_session, {
current_basemap <- isolate(input$basemap)
if (current_basemap != selected_basemap) {
return()
}
unique_suffix <- as.numeric(Sys.time()) * 1000
source_id <- paste0("raster_source_", unique_suffix)
layer_id <- paste0("raster_layer_", unique_suffix)
maplibre_proxy("map") %>%
add_raster_source(id = source_id, tiles = c(tile_url), tileSize = 256, attribution = attribution_text) %>%
add_layer(
id = layer_id,
type = "raster",
source = source_id,
paint = paint_props
)
stations_before_id(NULL)
current_raster_layers(c(layer_id))
style_change_trigger(isolate(style_change_trigger()) + 1)
})
}, delay = 0.5)
}
})
# Toggle Labels visibility
observeEvent(input$show_labels,
{
visibility <- if (input$show_labels) "visible" else "none"
label_layers <- c(
"place_villages", "place_town", "place_country_2", "place_country_1",
"place_state", "place_continent",
"place_city_r6", "place_city_r5", "place_city_dot_r7", "place_city_dot_r4",
"place_city_dot_r2", "place_city_dot_z7",
"place_capital_dot_z7", "place_capital",
"roadname_minor", "roadname_sec", "roadname_pri", "roadname_major",
"motorway_name",
"watername_ocean", "watername_sea", "watername_lake", "watername_lake_line",
"poi_stadium", "poi_park", "poi_zoo",
"airport_label",
"country-label", "state-label", "settlement-major-label", "settlement-minor-label",
"settlement-subdivision-label", "road-label", "waterway-label", "natural-point-label",
"poi-label", "airport-label"
)
proxy <- maplibre_proxy("map")
for (layer_id in label_layers) {
tryCatch(
{
proxy <- proxy %>% set_layout_property(layer_id, "visibility", visibility)
},
error = function(e) {
# Layer may not exist in current style, ignore silently
}
)
}
},
ignoreInit = TRUE
)
# Update Markers
observe({
# Wait for map to be ready before adding markers
req(map_initialized())
# Dependency on style change
style_change_trigger()
# Use system date for default link
current_year <- as.numeric(format(Sys.Date(), "%Y"))
resolution <- input$data_resolution
# Prepare data with labels for hover
data <- filtered_stations() %>%
add_resolution_period(resolution) %>%
mutate(
station_label = DisplayName,
station_type_label = ifelse(station_type == "a", "AMeDAS", "Manned station"),
period_start_label = ifelse(is.na(ResolutionStart), "Unknown", ResolutionStart),
period_end_label = ifelse(is.na(ResolutionEnd) | ResolutionEnd == current_year, "Present", ResolutionEnd),
period_label = ifelse(is.na(ResolutionStart), "Unknown", paste0(ResolutionStart, " - ", period_end_label)),
hover_content = generate_label(DisplayName, ID, station_type_label, period_start_label, period_end_label, ObservedVariables)
)
if (nrow(data) > 0) {
# Convert to sf for MapLibre
map_data <- st_as_sf(data, coords = c("Lon", "Lat"), crs = 4326)
maplibre_proxy("map") %>%
clear_layer("stations") %>%
add_circle_layer(
id = "stations",
source = map_data,
circle_color = "navy",
circle_radius = 6,
circle_stroke_color = "#00000000",
circle_stroke_width = 0,
circle_opacity = 0.7,
tooltip = "hover_content",
before_id = stations_before_id()
)
} else {
maplibre_proxy("map") %>% clear_layer("stations")
}
# Re-apply highlight if a station is selected
st <- isolate(selected_station())
if (!is.null(st)) {
# Re-gen label for consistency
period <- get_resolution_period(st, resolution)
period_labels <- format_period_labels(period$start, period$end, current_year)
lbl <- generate_label(
st$DisplayName, st$ID,
ifelse(st$station_type == "a", "AMeDAS", "Manned station"),
period_labels$start, period_labels$end,
st$ObservedVariables
)
highlight_selected_station(maplibre_proxy("map"), st$Lon, st$Lat, lbl)
}
})
# Observe marker clicks (MapLibre Feature Click)
observeEvent(input$map_feature_click, {
click <- input$map_feature_click
# Check if the click was on the "stations" layer
if (!is.null(click) && (isTRUE(click$layer_id == "stations") || isTRUE(click$layer == "stations"))) {
# The properties are available directly
props <- click$properties
# Note: simple feature properties might be lowercased or preserved depending on driver,
# usually they are preserved but let's check keys if issues arise.
# We passed `data` which had ID, DisplayName etc.
id_val <- props$ID # Assuming column name is preserved
if (!is.null(id_val)) {
# Use layerId (station ID)
closest <- stations %>%
filter(ID == id_val) %>%
head(1)
selected_station(closest)
# Highlight & Zoom
current_year <- as.numeric(format(Sys.Date(), "%Y"))
period <- get_resolution_period(closest, input$data_resolution)
period_labels <- format_period_labels(period$start, period$end, current_year = current_year)
lbl <- generate_label(
closest$DisplayName, closest$ID,
ifelse(closest$station_type == "a", "AMeDAS", "Manned station"),
period_labels$start, period_labels$end,
closest$ObservedVariables
)
highlight_selected_station(maplibre_proxy("map"), closest$Lon, closest$Lat, lbl)
# Sync sidebar filter
updateSelectizeInput(session, "station_selector", selected = closest$DisplayName)
# Start download directly
start_download(closest)
# Broadcast state to parent page for SEO URL sync
broadcast_state()
}
}
})
# Helper to calculate and enforce limits
# Returns a list(start=Date, end=Date, limited=FALSE) to be applied
check_and_limit_dates <- function(start, end, resolution, anchor = "end") {
limit_days <- Inf
limit_msg <- ""
if (resolution %in% c("Hourly", "10 Minutes")) {
limit_days <- 93 # ~3 months
limit_msg <- "3 months"
} else if (resolution == "Daily") {
limit_days <- 366 # 1 year
limit_msg <- "1 year"
} else if (resolution == "Monthly") {
limit_days <- 1826 # ~5 years
limit_msg <- "5 years"
}
diff <- as.numeric(end - start)
if (diff > limit_days) {
new_start <- start
new_end <- end
if (anchor == "start") {
# Start is fixed, adjust end
new_end <- start + limit_days
} else {
# End is fixed (default), adjust start
new_start <- end - limit_days
}
return(list(start = new_start, end = new_end, limited = TRUE, msg = limit_msg))
}
return(list(start = start, end = end, limited = FALSE))
}
# Auto-refetch when resolution changes
observeEvent(input$data_resolution, {
st <- selected_station()
req(st)
req(input$date_range)
start <- as.Date(input$date_range[1])
end <- as.Date(input$date_range[2])
# When changing resolution, usually we want to keep the End date and adjust Start if needed
res_val <- input$data_resolution
if (res_val == "Monthly") {
# Special rule for Monthly: Always default to 3 years back
new_start <- end - 365 * 3
res <- list(start = new_start, end = end, limited = TRUE, msg = "3 years")
} else {
res <- check_and_limit_dates(start, end, res_val, anchor = "end")
}
if (res$limited) {
updateDateRangeInput(session, "date_range", start = res$start, end = res$end)
showNotification(paste0("Range adjusted to ", res$msg, " for ", input$data_resolution, " resolution."), type = "warning")
# Update prev dates so we don't trigger the other observer unnecessarily
prev_dates(c(res$start, res$end))
}
# Trigger download
start_download(st, override_start = res$start, override_end = res$end)
})
# Auto-refetch and Smart Limit when Date Range Changes
observeEvent(input$date_range, {
st <- selected_station()
req(input$date_range)
current_dates <- input$date_range
current_start <- as.Date(current_dates[1])
current_end <- as.Date(current_dates[2])
last_dates <- prev_dates()
# Logic to determine anchor
anchor <- "end" # Default
if (!is.null(last_dates)) {
last_start <- as.Date(last_dates[1])
last_end <- as.Date(last_dates[2])
# If Start changed, anchor Start. If End changed, anchor End.
if (current_start != last_start) {
anchor <- "start"
}
# If both changed (rare in picker?) or just End, default to End anchor
}
# Check Limits
res <- check_and_limit_dates(current_start, current_end, input$data_resolution, anchor = anchor)
if (res$limited) {
# Update UI (this will trigger observer again, but logic should stabilize)
updateDateRangeInput(session, "date_range", start = res$start, end = res$end)
showNotification(paste0("Limit exceeded. Adjusted to ", res$msg, "."), type = "warning")
prev_dates(c(res$start, res$end))
# Download with CORRECTED dates
if (!is.null(st)) {
start_download(st, override_start = res$start, override_end = res$end)
}
} else {
# No limit applied, just update state
prev_dates(current_dates)
# Download with CURRENT dates
if (!is.null(st)) {
# Only download if effectively changed (checking prev might be good, but safe to call)
if (tab_down_trigger_check(current_dates, last_dates)) {
start_download(st, override_start = current_start, override_end = current_end)
}
}
}
})
# Helper to avoid double downloads if dates haven't materially changed
tab_down_trigger_check <- function(curr, last) {
if (is.null(last)) {
return(TRUE)
}
if (curr[1] != last[1] || curr[2] != last[2]) {
return(TRUE)
}
return(FALSE)
}
# --- Async Fetch State Machine ---
# State Variables
fetch_stage <- reactiveVal(0) # 0=Idle, 1=Init, 2=Next, 4=Download/Parse, 6=Merge
fetch_queue <- reactiveVal(list())
fetch_queue_idx <- reactiveVal(0)
parsed_data_list <- reactiveVal(list())
fetch_current_token <- reactiveVal(NULL)
final_data_path <- reactiveVal(NULL) # Path to the ready-to-download file
reset_fetch <- function() {
fetch_stage(0)
fetch_current_token(as.numeric(Sys.time()))
# Unfreeze UI
session$sendCustomMessage("unfreezeUI", list())
# We don't need to enable buttons since we depend on click
}
# Handle Cancel
observeEvent(input$cancel_loading, {
reset_fetch()
showNotification("Loading cancelled by user.", type = "warning")
})
start_download <- function(st, override_start = NULL, override_end = NULL) {
req(input$date_range)
# Clear existing data instantly to indicate refresh
station_data(NULL)
if (!is.null(override_start)) {
start_date <- as.Date(override_start)
} else {
start_date <- as.Date(input$date_range[1])
}
if (!is.null(override_end)) {
end_date <- as.Date(override_end)
} else {
end_date <- as.Date(input$date_range[2])
}
# --- Enforce Date Range Limits (Safety Check only) ---
# Note: The smart observers should handle UI updates and overrides.
# This block remains just to clamp wildly invalid args if they bypass observers.
# For simplicity, we trust overrides or input, assuming observers are working.
# ---------------------------------
# UI Setup
# shinyjs calls removed or replaced by freezeUI
msg <- paste0("Initializing fetch for ", st$DisplayName, "...")
session$sendCustomMessage("freezeUI", list(text = msg, station = st$DisplayName))
# Generate Queue based on resolution
resolution <- input$data_resolution
if (resolution %in% c("Daily", "Monthly")) {
# Daily/Monthly: iterate months
seq_start <- as.Date(format(start_date, format = "%Y-%m-01"))
seq_end <- as.Date(format(end_date, format = "%Y-%m-01"))
# Clamp to current month to avoid future requests
current_month_start <- as.Date(format(Sys.Date(), "%Y-%m-01"))
if (seq_end > current_month_start) {
seq_end <- current_month_start
}
dates <- seq(seq_start, seq_end, by = "month")
if (length(dates) == 0) {
showNotification("Invalid date range.", type = "error")
reset_fetch()
return()
}
# Create queue items (month-level)
q <- list()
for (i in seq_along(dates)) {
d <- dates[i]
yr <- as.numeric(format(d, format = "%Y"))
mo <- as.numeric(format(d, format = "%m"))
q[[length(q) + 1]] <- list(year = yr, month = mo, day = NULL)
}
} else {
# Hourly/10-min: iterate days
# Clamp end_date to today to avoid future requests
today <- Sys.Date()
if (end_date > today) {
end_date <- today
}
dates <- seq(start_date, end_date, by = "day")
if (length(dates) == 0) {
showNotification("Invalid date range.", type = "error")
reset_fetch()
return()
}
# Create queue items (day-level)
q <- list()
for (i in seq_along(dates)) {
d <- as.Date(dates[i], origin = "1970-01-01")
yr <- as.numeric(format(d, format = "%Y"))
mo <- as.numeric(format(d, format = "%m"))
dy <- as.numeric(format(d, format = "%d"))
q[[length(q) + 1]] <- list(year = yr, month = mo, day = dy)
}
}
fetch_queue(q)
fetch_queue_idx(1)
parsed_data_list(list())
fetch_current_token(as.numeric(Sys.time()))
fetch_stage(2) # Go to Next
}
# Stage 2: Next File
observe({
req(fetch_stage() == 2)
idx <- fetch_queue_idx()
q <- fetch_queue()
if (idx > length(q)) {
fetch_stage(6) # Merge
} else {
# UI Update
item <- q[[idx]]
if (is.null(item$day)) {
msg <- paste0(
"Fetching data for ", item$year, "-", sprintf("%02d", item$month),
" (", idx, "/", length(q), ")..."
)
} else {
msg <- paste0(
"Fetching data for ", item$year, "-", sprintf("%02d", item$month), "-", sprintf("%02d", item$day),
" (", idx, "/", length(q), ")..."
)
}
session$sendCustomMessage("freezeUI", list(text = msg))
fetch_stage(4) # Go to Download/Parse
}
})
# Stage 4: Download & Parse (Async Yield)
observe({
req(fetch_stage() == 4)
token <- fetch_current_token()
later::later(function() {
if (!identical(isolate(fetch_current_token()), token)) {
return()
}
isolate({
idx <- fetch_queue_idx()
q <- fetch_queue()
item <- q[[idx]]
st <- selected_station()
# Call Scraper
df <- tryCatch(
get_jma_data(
block_no = st$ID,
year = item$year,
month = item$month,
day = item$day,
prec_no = st$prec_no,
type = paste0(st$station_type, "1"),
resolution = input$data_resolution
),
error = function(e) NULL
)
if (!is.null(df)) {
if ("Day" %in% names(df)) {
df <- df %>%
mutate(Date = as.Date(sprintf("%04d-%02d-%02d", Year, Month, Day)))
}
plist <- parsed_data_list()
plist[[length(plist) + 1]] <- df
parsed_data_list(plist)
}
fetch_queue_idx(idx + 1)
fetch_stage(2)
})
}, 0.5)
fetch_stage(-1)
})
# Stage 6: Merge & Finalize
observe({
req(fetch_stage() == 6)
token <- fetch_current_token()
msg <- "Merging data..."
session$sendCustomMessage("freezeUI", list(text = msg))
later::later(function() {
if (!identical(isolate(fetch_current_token()), token)) {
return()
}
isolate({
plist <- parsed_data_list()
if (length(plist) == 0) {
showNotification("No data found for the selected range.", type = "warning", session = session)
reset_fetch()
return()
}
# Merge
final_df <- bind_rows(plist)
# Filter precise range
start_date <- input$date_range[1]
end_date <- input$date_range[2]
if ("Date" %in% names(final_df)) {
final_df <- final_df %>%
filter(Date >= start_date & Date <= end_date) %>%
select(-Date)
}
if (nrow(final_df) == 0) {
showNotification("No data in exact range.", type = "warning", session = session)
reset_fetch()
return()
}
# Save to temp file for downloadHandler
tmp <- tempfile(fileext = ".xlsx")
write_xlsx(final_df, path = tmp)
final_data_path(tmp)
# Update reactive for display
station_data(final_df)
# Success UI
reset_fetch()
# Switch to "Dashboard" tab
if (is.null(input$main_nav) || input$main_nav != "Dashboard") {
session$sendCustomMessage("switchTab", list(tabId = "Dashboard"))
}
showNotification(paste("Successfully loaded", nrow(final_df), "rows."), type = "message", session = session)
})
}, 0.1)
fetch_stage(-1)
})
# Output: Station Ready (Controls Dashboard Visibility)
output$station_ready <- reactive({
!is.null(station_data())
})
outputOptions(output, "station_ready", suspendWhenHidden = FALSE)
# Output: Station Info Header (Dashboard Card)
output$station_info_header <- renderUI({
st <- selected_station()
if (is.null(st)) {
return(NULL)
}
# Determine Resolution Badge
res_label <- input$data_resolution
res_class <- if (res_label == "Hourly") "bg-primary" else "bg-success"
# Data Range Text
df <- station_data()
if (is.null(df) || nrow(df) == 0) {
dates_text <- "No data loaded"
} else {
# Assumes 'Date' column exists for Daily, or constructing from Year/Month/Day
if ("Date" %in% names(df)) {
d_range <- range(df$Date, na.rm = TRUE)
dates_text <- paste(d_range[1], "to", d_range[2])
} else if ("Year" %in% names(df) && "Month" %in% names(df) && "Day" %in% names(df)) {
# Hourly/10min
# Construct dates just for display
start_d <- as.Date(paste(df$Year[1], df$Month[1], df$Day[1], sep = "-"))
n <- nrow(df)
end_d <- as.Date(paste(df$Year[n], df$Month[n], df$Day[n], sep = "-"))
dates_text <- paste(start_d, "to", end_d)
} else {
dates_text <- "Loaded"
}
}
# Render Card
card(
style = "margin-bottom: 20px; border-left: 5px solid #007bff;",
card_body(
padding = 15,
layout_columns(
fill = FALSE,
# Col 1: Station
div(
strong("Station"), br(),
span(st$DisplayName, style = "font-size: 1.1rem;"), br(),
tags$small(class = "text-muted", paste("ID:", st$ID))
),
# Col 2: Location
div(
strong("Location"), br(),
span(paste0(st$Lat, "°N, ", st$Lon, "°E")), br(),
tags$small(class = "text-muted", st$PrecLabel)
),
# Col 3: Technical
div(
strong("Technical"), br(),
span(paste0(st$Elevation, " m")), br(),
span(class = paste("badge", res_class), res_label)
),
# Col 4: Period
div(
strong("Data Selection"), br(),
span(dates_text)
),
# Col 5: Actions
div(
class = "d-flex align-items-center justify-content-end",
downloadButton(
"downloadData",
label = "Export Excel",
class = "btn-sm btn-primary",
icon = icon("file-excel")
)
)
)
)
)
})
# Download Handler
output$downloadData <- downloadHandler(
filename = function() {
req(selected_station())
st <- selected_station()
res <- if (!is.null(input$data_resolution)) tolower(input$data_resolution) else "data"
sprintf("jma_%s_%s_%s.xlsx", st$ID, res, format(Sys.Date(), "%Y%m%d"))
},
content = function(file) {
req(final_data_path())
file.copy(final_data_path(), file)
}
)
# Render Table
# --- Dashboard Logic ---
# 1. Update Variable Choices when data changes
# 2. Render Plot
# --- Dynamic Plot Rendering ---
# 1. Determine available variables and render UI
output$jma_plots_container <- renderUI({
df <- station_data()
req(df)
# Identify available data
# We check columns availability
plot_list <- tagList()
# Temperature
has_temp <- any(c("Temperature", "Temp_Mean", "Temp_Max", "Temp_Min") %in% names(df) & !all(is.na(df[[if ("Temperature" %in% names(df)) "Temperature" else "Temp_Mean"]])))
if (has_temp) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_temp", height = "350px")))
}
# Precipitation
if ("Precipitation" %in% names(df) && !all(is.na(df$Precipitation))) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_precip", height = "300px")))
}
# Wind
if ("Wind_Speed" %in% names(df) && !all(is.na(df$Wind_Speed))) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_wind", height = "350px")))
}
# Humidity
if ("Humidity" %in% names(df) && !all(is.na(df$Humidity))) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_humidity", height = "350px")))
}
# Pressure
if ("Pressure" %in% names(df) && !all(is.na(df$Pressure))) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_pressure", height = "300px")))
}
# Sunshine
has_sun <- any(c("Sunshine_Hours", "Sunshine_Minutes") %in% names(df) & !all(is.na(df[[if ("Sunshine_Hours" %in% names(df)) "Sunshine_Hours" else "Sunshine_Minutes"]])))
if (has_sun) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_sunshine", height = "300px")))
}
# Solar
if ("Solar_Radiation" %in% names(df) && !all(is.na(df$Solar_Radiation))) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_solar", height = "300px")))
}
# Snow
has_snow <- any(c("Snow_Depth", "Snowfall") %in% names(df) & !all(is.na(df[[if ("Snow_Depth" %in% names(df)) "Snow_Depth" else "Snowfall"]])))
if (has_snow) {
plot_list <- tagList(plot_list, div(class = "col-12 col-lg-6", plotlyOutput("plot_snow", height = "300px")))
}
if (length(plot_list) == 0) {
return(div(class = "alert alert-warning", "No plottable weather data found for this period."))
}
# Wrap in grid container (2 columns)
div(
class = "row g-3",
style = "padding: 10px;",
plot_list
)
})
# 2. Render individual plots
# We use observer to ensure data is fresh, although standard renderPlotly also works if outputs are defined.
# We define all possible outputs throughout. Even if they are not in UI, it's fine.
# Helper to prepare data with DateTime
get_plot_df <- reactive({
df <- station_data()
req(df)
res <- input$data_resolution
if (res == "Monthly") {
df$DateTime <- as.Date(paste(df$Year, df$Month, "01", sep = "-"))
} else if (res == "Daily") {
df$DateTime <- as.Date(paste(df$Year, df$Month, df$Day, sep = "-"))
} else if (res == "Hourly") {
df$DateTime <- as.POSIXct(paste0(df$Year, "-", df$Month, "-", df$Day, " ", df$Hour, ":00"), format = "%Y-%m-%d %H:%M")
} else { # 10 Minutes
df$DateTime <- as.POSIXct(paste0(df$Year, "-", df$Month, "-", df$Day, " ", df$Time), format = "%Y-%m-%d %H:%M")
}
df
})
output$plot_temp <- renderPlotly({
df <- get_plot_df()
create_temperature_plot_jma(df, input$data_resolution)
})
output$plot_precip <- renderPlotly({
df <- get_plot_df()
create_precipitation_plot_jma(df, input$data_resolution)
})
output$plot_wind <- renderPlotly({
df <- get_plot_df()
resolution <- input$data_resolution
if (resolution == "Hourly") {
create_wind_rose_jma(df, resolution)
} else {
create_wind_plot_jma(df, resolution)
}
})
output$plot_humidity <- renderPlotly({
df <- get_plot_df()
create_humidity_plot_jma(df, input$data_resolution)
})
output$plot_pressure <- renderPlotly({
df <- get_plot_df()
create_pressure_plot_jma(df, input$data_resolution)
})
output$plot_sunshine <- renderPlotly({
df <- get_plot_df()
create_sunshine_plot_jma(df, input$data_resolution)
})
output$plot_solar <- renderPlotly({
df <- get_plot_df()
create_solar_plot_jma(df, input$data_resolution)
})
output$plot_snow <- renderPlotly({
df <- get_plot_df()
create_snow_plot_jma(df, input$data_resolution)
})
# --- Data Table Renderer ---
output$daily_data <- DT::renderDataTable({
req(station_data())
df <- station_data()
# Column Mapping
col_map <- c(
"Year" = "Year",
"Month" = "Month",
"Day" = "Day",
"Hour" = "Hour",
"Time" = "Time",
"Pressure" = "Pressure [hPa]",
"Pressure_Sea_Level" = "Sea Level Pressure [hPa]",
"Precipitation" = "Precipitation [mm]",
"Precipitation_Max_1h" = "Max 1h Precipitation [mm]",
"Precipitation_Max_10min" = "Max 10m Precipitation [mm]",
"Temp_Mean" = "Mean Temperature [°C]",
"Temp_Max" = "Max Temperature [°C]",
"Temp_Min" = "Min Temperature [°C]",
"Temperature" = "Temperature [°C]",
"Humidity" = "Humidity [%]",
"Humidity_Min" = "Min Humidity [%]",
"Wind_Speed" = "Wind Speed [m/s]",
"Wind_Speed_Max" = "Max Wind Speed [m/s]",
"Wind_Gust_Speed" = "Max Gust Speed [m/s]",
"Wind_Direction" = "Wind Direction",
"Wind_Direction_Deg" = "Wind Direction [Deg]",
"Sunshine_Hours" = "Sunshine Duration [h]",
"Sunshine_Minutes" = "Sunshine Duration [min]",
"Solar_Radiation" = "Solar Radiation [MJ/m²]",
"Snowfall" = "Snowfall [cm]",
"Snow_Depth" = "Snow Depth [cm]",
"Snow_Days" = "Snow Days",
"Fog_Days" = "Fog Days",
"Thunder_Days" = "Thunder Days",
"Dew_Point" = "Dew Point [°C]",
"Vapor_Pressure" = "Vapor Pressure [hPa]",
"Cloud_Cover" = "Cloud Cover [1/10]",
"Visibility" = "Visibility [km]"
)
# Rename columns that exist in the map
current_names <- names(df)
for (i in seq_along(current_names)) {
cn <- current_names[i]
if (cn %in% names(col_map)) {
current_names[i] <- col_map[[cn]]
}
}
names(df) <- current_names
DT::datatable(df, options = list(pageLength = 15, scrollX = TRUE))
})
# Zoom to station if selected (sidebar)
observeEvent(input$station_selector, {
if (input$station_selector != "All") {
closest <- stations %>%
filter(DisplayName == input$station_selector) %>%
head(1)
if (nrow(closest) > 0) {
selected_station(closest)
# Highlight & Zoom
period <- get_resolution_period(closest, input$data_resolution)
period_labels <- format_period_labels(period$start, period$end, current_year = as.numeric(format(Sys.Date(), "%Y")))
lbl <- generate_label(
closest$DisplayName, closest$ID,
ifelse(closest$station_type == "a", "AMeDAS", "Manned station"),
period_labels$start, period_labels$end,
closest$ObservedVariables
)
highlight_selected_station(maplibre_proxy("map"), closest$Lon, closest$Lat, lbl)
# Start download
start_download(closest)
}
}
})
# Station Info Table (Metadata for filtered stations)
output$station_info_table <- DT::renderDataTable({
df <- filtered_stations()
req(df)
# Select relevant columns based on resolution
res <- input$data_resolution
# Determine which start/end columns to show
start_col <- switch(res,
"Daily" = "Start_Daily",
"Hourly" = "Start_Hourly",
"10 Minutes" = "Start_10min",
"Monthly" = "Start_Monthly"
)
end_col <- switch(res,
"Daily" = "End_Daily",
"Hourly" = "End_Hourly",
"10 Minutes" = "End_10min",
"Monthly" = "End_Monthly"
)
df_display <- df %>%
select(
ID,
Name = DisplayName,
Prefecture = PrecLabel,
Lat,
Lon,
Elevation,
Variables = ObservedVariables,
!!sym(start_col),
!!sym(end_col)
)
DT::datatable(df_display,
selection = "none",
rownames = FALSE,
callback = DT::JS("
table.on('dblclick', 'tr', function() {
var rowData = table.row(this).data();
if (rowData !== undefined && rowData !== null) {
var stationId = rowData[0];
Shiny.setInputValue('station_info_table_dblclick', stationId, {priority: 'event'});
}
});
"),
options = list(
pageLength = 20,
scrollX = TRUE,
searching = TRUE
)
)
})
# Selection from Table - Double Click
observeEvent(input$station_info_table_dblclick, {
id_val <- input$station_info_table_dblclick
req(id_val)
closest <- stations %>%
filter(ID == id_val) %>%
head(1)
req(nrow(closest) > 0)
selected_station(closest)
# Highlight & Zoom
period <- get_resolution_period(closest, input$data_resolution)
period_labels <- format_period_labels(period$start, period$end, current_year = as.numeric(format(Sys.Date(), "%Y")))
lbl <- generate_label(
closest$DisplayName, closest$ID,
ifelse(closest$station_type == "a", "AMeDAS", "Manned station"),
period_labels$start, period_labels$end,
closest$ObservedVariables
)
highlight_selected_station(maplibre_proxy("map"), closest$Lon, closest$Lat, lbl)
# Start download
start_download(closest)
# Broadcast state to parent page for SEO URL sync
broadcast_state()
# Switch to Dashboard? The DWD app implies it ("view the dashboard").
# The download will eventually auto-switch, but let's be explicit if needed.
# Ideally the download logic (Stage 6) handles the switch.
# But for UX, double-clicking implies "Go there".
})
# Observer: Broadcast state when main tab changes
observeEvent(input$main_nav,
{
req(url_initialized())
broadcast_state()
},
ignoreInit = TRUE
)
# Observer: Broadcast state when dashboard subtab changes
observeEvent(input$dashboard_subtabs,
{
req(url_initialized())
broadcast_state()
},
ignoreInit = TRUE
)
# Observer: Broadcast state when resolution changes
observeEvent(input$data_resolution,
{
req(url_initialized())
broadcast_state()
},
ignoreInit = TRUE
)
# Observer: Broadcast state when date range changes
observeEvent(input$date_range,
{
req(url_initialized())
broadcast_state()
},
ignoreInit = TRUE
)
}