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( "
", "", htmltools::htmlEscape(name), " (", id, ")
", "Type: ", type, "
", "Period: ", start, " - ", end, "
", "
", "Vars: ", substring(vars, 1, 50), "...", "
", "
" ) } 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")) { '© OpenStreetMap contributors' } else { "Tiles © 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 ) }