Commit 715f52d8 authored by Conor Anderson's avatar Conor Anderson

Major overhaul of crop interface and festures.

Squashed commit of the following:

commit eb236762
Author: Conor Anderson <[email protected]>
Date:   Wed May 27 07:36:12 2020 -0400

    Add note to README

commit ecc5817b
Author: Conor Anderson <[email protected]>
Date:   Thu May 21 08:43:01 2020 -0400

    Better error detection in cropncdf

commit 4fab42b5
Author: Conor Anderson <[email protected]>
Date:   Thu May 21 07:48:23 2020 -0400

    Fix year in copyright

commit 70931d3a
Author: Conor Anderson <[email protected]>
Date:   Thu May 21 07:46:10 2020 -0400

    Fix some option labels

commit 0c2d9218
Author: Conor Anderson <[email protected]>
Date:   Thu May 21 07:36:11 2020 -0400

    Update .gitignore

commit a1ce26b6
Author: Conor Anderson <[email protected]>
Date:   Thu May 21 07:28:50 2020 -0400

    User-facing updates for this implementation

commit 35461e38
Author: Conor Anderson <[email protected]>
Date:   Tue May 19 07:21:09 2020 -0400

    Allow cropping multiple files
parent b3170357
......@@ -2,5 +2,5 @@
.Rproj.user
local_settings.R
plumber_settings.R
shiny/shiny.Rproj
updateip.sh
*.Rproj
*.sh
......@@ -11,8 +11,9 @@ RUN apt-get update &&\
apt-get clean && rm -rf /tmp/* /var/lib/apt/lists/*
RUN cd /api &&\
Rscript -e "source('https://install-github.me/pacificclimate/ncdf4.helpers')" &&\
Rscript -e "source('https://gitlab.com/ConorIA/conjuntool/snippets/1788463/raw')" &&\
Rscript -e "install.packages('remotes', repos = 'https://cran.r-project.org/')" &&\
Rscript -e "remotes::install_github('pacificclimate/ncdf4.helpers')" &&\
Rscript -e "source('https://gitlab.com/ConorIA/conjuntool/snippets/1978145/raw')" &&\
rm -rf /tmp/*
CMD ["/api/plumber.R"]
library(base64enc)
library(callr)
library(dplyr)
library(jsonlite)
library(lubridate)
......@@ -315,7 +316,7 @@ function(key, req, res){
chbr = brick(file.path(file_dir, cmip, components['var'], "verified", filename))
if (attr(extent(chbr), "xmax") > 180) chbr = rotate(chbr)
if (is(shp, "SpatialPolygonsDataFrame")) {
if (is(shp, "SpatialPolygons")) {
message("Working with a shapefile")
if (!identical(crs(shp), crs(chbr))) shp = spTransform(shp, crs(chbr))
chbr.crop <- crop(chbr, shp)
......@@ -355,6 +356,7 @@ function(key, req, res){
return(res)
}
# If the URL gets called the browser will automatically download the file.
#* @serializer contentType list(type="application/octet-stream")
#* @param filepath The file to return for download
......@@ -371,3 +373,172 @@ function(filepath, res) {
}
readBin(filepath, "raw", n = file.info(filepath)$size)
}
#* Crop netcdf files and post to dav
#* @post /cropncdf
#* @json
function(req, res){
request <- jsonlite::fromJSON(req$postBody)
request$meta <- as_tibble(request$meta)
request$shp <- unserialize(jsonlite::base64_dec(request$shp))
proc <- callr::r_bg(function(request) {
source("plumber_settings.R")
## Make some minor changes to accomodate CMIP6
get.split.filename.cmip <- function(cmip.file) {
split.path <- strsplit(cmip.file, "/")[[1]]
fn.split <- strsplit(tail(split.path, n=1), "_")[[1]]
names(fn.split) <- c("var", "tres", "model", "emissions", "run",
(if (length(fn.split) == 7) "grid" else NULL),
"trange")
fn.split[length(fn.split)] <- strsplit(fn.split[length(fn.split)], "\\.")[[1]][1]
fn.split[c('tstart', 'tend')] <- strsplit(fn.split['trange'], "-")[[1]]
fn.split
}
user_folder <- stringr::str_replace_all(stringr::str_replace_all(request$email, "@", "_AT_"), "\\.", "_DOT_")
request_time <- format(Sys.time(), format = "%Y%m%d_%H%M%S")
for (dir in c(dav_root, file.path(dav_root, user_folder), file.path(dav_root, user_folder, request_time))) {
r <- httr::GET(URLencode(dir),
httr::authenticate(dav_user, dav_pass))
if (r$status_code == 404) {
message("Creating ", dir)
r <- httr::VERB("MKCOL", url = URLencode(dir),
httr::authenticate(dav_user, dav_pass))
} else if (r$status_code == 200) {
message("Directory ", dir, " exists")
} else {
stop("Unexpected status code ", r$status_code, r$content)
}
}
tdir <- tempdir()
shp_bak <- request$shp
errors <- NULL
for (row in 1:nrow(request$meta)) {
shp <- shp_bak
working_on <- paste(unlist(dplyr::select(request$meta, Model:End)[row,]), collapse = " ")
message("Working on: ", working_on)
filenames <- request$meta$Files[[row]]
components <- tibble::as_tibble(t(sapply(filenames, get.split.filename.cmip)))
cmip <- ifelse(tibble::has_name(components, "grid"), "CMIP6", "CMIP5")
var <- unique(components[['var']])
if (length(var) > 1) stop("Too many vars")
nc_nc <- ncdf4::nc_open(file.path(file_dir, cmip, var, "verified", filenames[1]), readunlim = FALSE)
var_details <- nc_nc$var[[var]]
ncdf4::nc_close(nc_nc)
bricks <- try(lapply(filenames, function(x) raster::brick(file.path(file_dir, cmip, var, "verified", x))))
if (inherits(bricks, "try-error")) {
errors <- c(errors, paste("Error reading", working_on))
next
}
st <- try(do.call(raster::stack, bricks))
if (inherits(st, "try-error")) {
errors <- c(errors, paste("Error stacking", working_on))
next
}
rm(bricks); invisible(gc())
pstart <- as.character(request$period[1])
pend <- as.character(request$period[length(request$period)])
start = min(which(stringr::str_detect(names(st), pstart)))
end = max(which(stringr::str_detect(names(st), pend)))
st <- raster::subset(st, start:end)
if (is(shp, "SpatialPolygons")) {
message("Working with a shapefile")
if (attr(raster::extent(st), "xmax") > 180) shp <- sp::recenter(shp)
if (!identical(raster::crs(shp), raster::crs(st))) shp <- sp::spTransform(shp, raster::crs(st))
st.crop <- try(raster::crop(st, shp))
if (inherits(st.crop, "try-error")) {
errors <- c(errors, paste("Error cropping", working_on))
next
}
rm(st); invisible(gc())
st.out = try(raster::mask(st.crop, shp))
if (inherits(st.out, "try-error")) {
errors <- c(errors, paste("Error masking", working_on))
next
}
rm(st.crop); invisible(gc())
} else {
stop("The shp object is an unexpected type")
}
fileout <- sprintf("%s_%s_%s_%s_%s_%s_crop.nc",
var,
components$tres[1],
components$model[1],
paste(components$emissions, collapse = "-"),
components$run[1],
paste0(pstart, "01", "-", pend, "12"))
writeout <- file.path(tdir,fileout)
written <- try(raster::writeRaster(st.out, writeout, overwrite=TRUE, format="CDF",
varname=var_details$name,
varunit=var_details$units,
longname=var_details$longname,
xname=var_details$dim[[1]]$name,
yname=var_details$dim[[2]]$name,
zname=var_details$dim[[3]]$name))
if (!inherits(written, "try-error")) {
message("File written to ", writeout)
link <- file.path(dav_root, user_folder, request_time, fileout)
r <- httr::PUT(link,
body = httr::upload_file(writeout),
httr::authenticate(dav_user, dav_pass))
if (r$status_code == 201) {
message("File uploaded successfully")
unlink(writeout)
} else {
errors <- c(errors, paste("Error uploading", working_on))
unlink(writeout)
}
} else {
message("Error writing ", writeout)
errors <- c(errors, paste("Error writing", working_on))
}
}
if (!is.null(errors) && length(errors) == nrow(request$meta)) {
body <- paste("We are sorry to say that we encountered errors processing your <strong>Conjuntool</strong> data request.<br>",
"Please contact the author for help with the following errors:<br><br>",
paste0(errors,"<br>"))
} else {
body <- paste("We did it! Your <strong>Conjuntool</strong> data request should be available at:<br>", file.path(dav_root, user_folder, request_time))
if (!is.null(errors)) {
body <- paste(body, "<br><br>Please note that we encountered the following errors:<br><br>",
paste0(errors,"<br>"))
}
}
req_body <- list(from = sprintf("Conjuntool <%s>", mailgun_from),
to = request$email,
subject = "Your Conjuntool data order",
html = body)
r <- httr::POST(paste0("https://api.mailgun.net/v3/", mailgun_domain, "/messages"),
httr::authenticate("api", mailgun_api_key), encode = "form", body = req_body)
}, args = list(request))
res$status <- 200
res$body <- jsonlite::toJSON(auto_unbox = TRUE, list(
status = 200,
message = "Data was submitted. An email will be sent upon success."
))
return(res)
}
......@@ -19,6 +19,17 @@ st_point = storr::storr_rds(file.path(cache_root, "point-cache"), default_namesp
st_meta = storr::storr_rds(file.path(cache_root, "meta-cache"), default_namespace = "meta")
cache_ver = "2018-12-06"
# Users and Passwords
# Plumber API users and passwords
users = c()
passwords = c()
# Extra credentials for the cropncdf endpoint
## WebDav details for files from the cropncdf endpoint to be uploaded
dav_user = ""
dav_pass = ""
dav_root = "https://dav.example.com/cjt_dist"
## Mailgun API details for the cropncdf endpoint notifications to be sent
mailgun_api_key = ""
mailgun_domain = "mailgun.example.com"
mailgun_from = "[email protected]"
......@@ -10,8 +10,8 @@ RUN apt-get update &&\
RUN mv /srv/shiny-server/app/shiny-server.conf /etc/shiny-server/shiny-server.conf
RUN cd /srv/shiny-server/app &&\
Rscript -e "install.packages(\"devtools\", repos = \"https://cloud.r-project.org/\")" &&\
Rscript -e "source(\"https://gitlab.com/ConorIA/conjuntool/snippets/1788463/raw\")" &&\
Rscript -e "devtools::install_github(\"rstudio/DT\")" &&\
Rscript -e "install.packages(c('remotes', 'jsonlite'), repos = 'https://cloud.r-project.org/')" &&\
Rscript -e "source('https://gitlab.com/ConorIA/conjuntool/snippets/1978145/raw')" &&\
Rscript -e "remotes::install_github('rstudio/DT')" &&\
sudo -u shiny bash -c "Rscript -e \"webshot::install_phantomjs()\"" &&\
rm -rf /tmp/*
crop_req <- function(key, shp) {
if (debug_flag) message("Asking plumber to crop ", key)
r <- POST(URLencode(paste0(plumber_address, "/crop?key=", key)),
config = list(add_headers(accept = "application/octet-stream")),
authenticate(plumber_user, plumber_password),
body = base64encode(serialize(shp, NULL)), encode = "json")
crop_req <- function(request) {
if (debug_flag) message("Asking plumber to crop ", nrow(request$meta), " models")
if (r$status_code != 200) {
stop("There was an error")
}
r <- POST(URLencode(paste0(plumber_address, "cropncdf")),
config = list(add_headers(accept = "application/json")),
authenticate(plumber_user, plumber_password),
body = request, encode = "json")
res <- jsonlite::parse_json(rawToChar(content(r)))
URLencode(res$filename, reserved = TRUE)
return(r$status_code == 200)
}
......@@ -10,19 +10,18 @@ get_gcm_files <- function(choices, baseline = NULL, projection = NULL, model = N
}
# Do we need to borrow from the experiments for our baseline?
coyote <- (!is.null(baseline) && paste0(baseline[length(baseline)], "12") > "200512")
coyote <- ((!is.null(baseline) && paste0(baseline[length(baseline)], "12") > "200512") || (is.null(baseline) && paste0(projection[0], "01") < "200601"))
pending_ensembles <- if (is.null(ensemble)) unique(choices$Ensemble) else ensemble
pending_scenarios <- if (is.null(scenario)) unique(choices$Scenario[-which(choices$Scenario == "historical")]) else scenario
if (is.null(baseline)) period <- projection
if (is.null(projection)) {
if (coyote) {
tmp <- expand.grid("historical", pending_scenarios, stringsAsFactors = FALSE)
pending_scenarios <- list()
for (i in 1:nrow(tmp)) pending_scenarios <- c(pending_scenarios, list(unname(unlist(tmp[i,]))))
rm(tmp)
} else {
pending_scenarios <- "historical"
}
if (coyote) {
tmp <- expand.grid("historical", pending_scenarios, stringsAsFactors = FALSE)
pending_scenarios <- list()
for (i in 1:nrow(tmp)) pending_scenarios <- c(pending_scenarios, list(unname(unlist(tmp[i,]))))
rm(tmp)
} else if (is.null(projection)) {
pending_scenarios <- "historical"
period <- baseline
}
......
......@@ -11,6 +11,7 @@ _Conjuntool_ is a free, open source platform for the accessing and processing Ge
1. __GCM Plots__: Plot time series of GCM data, and produce monthly or annual time series for download.
2. __GCM Anomalies__: Read GCM data and generate $\Delta T$ Anomalies (change factors) over a chosen baseline.
3. __Overlay Map__: Show an overlay map of the time-period average selected data over a user-defined period.
4. __Crop Data__: Request spatial and temporal subsets of the model data (experimental)
## Why "_Conjuntool_"?:
......
On this page you can crop the GCM data from a single model. You can choose to upload an ESRI shapefile (with the necessary auxilary files) in a single ZIP archive, or you can manually choose a single point, or two corners of a rectangle object.
On this page you can crop the GCM data from the available models. You can choose to upload an ESRI shapefile (with the necessary auxilary files) in a single ZIP archive, or you can manually choose two corners of a rectangle object. You will receive an email at the email that you provide when the data are ready for download.
Click on the map to choose coordinates. You can verify or modify the coordinates in the "Manual Coordinates" panel.
......@@ -10,4 +10,4 @@ You should _not_ provide both a Shapefile _and_ manual input. However, if in dou
- If you want to use a different Shapefile, uploading it will replace any previously uploaded Shapefiles.
**This functionality remains a work in progress and is only lightly tested. In particular, the only models that are available are those that are packaged as a single _.nc_ file from 2006 to 2100. Models that are packaged by decade, for example, are not available, nor are the historical runs. Sometime the extent that is passed up to raster doesn't work. If that happens, try again, or try using a shapefile.**
**This functionality is very fresh and is only lightly tested. There are probably a lot of bugs for which there is no code to handle them. Please report bugs to the [Conjuntool issues page](https://gitlab.com/ConorIA/conjuntool). Please also note that there is a known issue: sometimes the extent that is passed up to raster doesn't work. If that happens, try again, or try using a shapefile.**
### Conjuntool, a free, open source tool for the analysis of GCM data from CMIP5.
<b>Copyright (C) 2018 Conor I. Anderson</b>
<b>Copyright (C) 2018&ndash;2020 Conor I. Anderson</b>
The source code for this project is available at [gitlab.com/ConorIA/conjuntool](https://gitlab.com/ConorIA/conjuntool).
......
......@@ -458,11 +458,31 @@ shinyServer(function(input, output, session) {
)
### Resample Data (WIP)
crop_model_in <- callModule(singleModelSelect, "crop_model_in",
choices = reactive(get_choices(input$var_filter_crop, lim = TRUE)),
coords = reactiveVal(NULL),
projection = reactiveVal(NULL))
get_choices_crop <- reactive({
get_choices(input$var_filter_crop, lim = FALSE)
})
output$ScenarioFilter_crop = renderUI({
choices <- get_choices_crop()
scenarios <- unname(unlist(unique(choices %>% dplyr::select(Scenario))))
checkboxGroupInput("scen_filter_in_crop", "Select Scenarios", scenarios[-1])
})
output$RunFilter_crop = renderUI({
choices <- get_choices_crop()
checkboxGroupInput("run_filter_in_crop",
"Select Ensembles/runs",
unname(unlist(unique(choices %>% dplyr::select(Ensemble)))), "r1i1p1")
})
output$ModFilter_crop = renderUI({
choices <- get_choices_crop()
checkboxGroupInput("mod_filter_in_crop",
"Select Model",
unname(unlist(unique(choices %>% dplyr::select(Model)))))
})
output$resample_map <- renderLeaflet({
leaflet() %>%
addProviderTiles("CartoDB.Positron") %>%
......@@ -535,57 +555,92 @@ shinyServer(function(input, output, session) {
}
})
## This regex from: https://stackoverflow.com/q/43661458
isValidEmail <- function(x) {
(!is.null(x) && grepl("\\<[A-Z0-9._%+-][email protected][A-Z0-9.-]+\\.[A-Z]{2,}\\>", as.character(x),
ignore.case = TRUE))
}
output$latlon_checkbox <- renderUI({
model <- try(req(crop_model_in()))
selections <- if (!any(is.null(c(input$scen_filter_in_crop, input$run_filter_in_crop, input$mod_filter_in_crop)))) {
length(input$var_filter_crop) * length(input$scen_filter_in_crop) * length(input$run_filter_in_crop) * length(input$mod_filter_in_crop)
} else {
0
}
shp <- try(req(input$shapefile))
content <- '<b>To do:</b>'
if (all(c(input$Lat1, input$Lat2, input$Lon1, input$Lon2) %in% -999) &&
content <- '<b>To do:</b><br>'
if (!isValidEmail(input$crop_email)) {
content <- c(content, '<i class="far fa-square"></i> Enter an email address.')
} else {
content <- c(content, '<i class="far fa-check-square"></i> Enter an email address.</i>')
}
if (any(c(input$Lat1, input$Lat2, input$Lon1, input$Lon2) %in% -999) &&
is.null(input$shapefile)) {
content <- c(content, '<i class="far fa-square"></i> Select at least one point or upload a .shp')
content <- c(content, '<i class="far fa-square"></i> Select two points or upload a .shp')
} else {
content <- c(content, '<i class="far fa-check-square"></i> Select at least one point or upload a .shp')
content <- c(content, '<i class="far fa-check-square"></i> Select two points or upload a .shp')
}
if (inherits(model, "try-error")) {
content <- c(content, '<i class="far fa-square"></i> Choose a mod/var/scen to process.')
if (!selections %in% 1:20) {
content <- c(content, '<i class="far fa-square"></i> Choose up to 20 var/scen/mod/run to process.')
} else {
content <- c(content, '<i class="far fa-check-square"></i> Choose a mod/var/scen to process.</i>')
content <- c(content, '<i class="far fa-check-square"></i> Choose up to 20 var/scen/mod/run to process.')
}
content <- c(content, paste("<b><br>Your selections:", selections, "</b>"))
HTML(paste(content, collapse = "<br>"))
})
output$crop_action <- renderUI({
if ((!all(c(input$Lat1, input$Lat2, input$Lon1, input$Lon2) %in% -999) ||
!is.null(input$shapefile)) &
!is.null(crop_model_in())) {
if ((!any(c(input$Lat1, input$Lat2, input$Lon1, input$Lon2) %in% -999) ||
!is.null(input$shapefile)) && isValidEmail(input$crop_email) &&
(!any(is.null(c(input$scen_filter_in_crop, input$run_filter_in_crop, input$mod_filter_in_crop))) &&
(length(input$var_filter_crop) * length(input$scen_filter_in_crop) * length(input$run_filter_in_crop) * length(input$mod_filter_in_crop)) <= 20)) {
actionButton("crop_go", "Process!")
} else {
NULL
}
})
get_crop_link <- eventReactive(input$crop_go, {
observeEvent(input$crop_go, {
message("Button pressed")
meta <- crop_model_in()
key = unlist(meta$Files)
if (!is.null(input$shapefile)) {
if (!any(c(input$Lat1, input$Lat2, input$Lon1, input$Lon2) %in% -999)) {
bbox <- matrix(c(input$Lon1, input$Lat1,
input$Lon1, input$Lat2,
input$Lon2, input$Lat2,
input$Lon2, input$Lat1,
input$Lon1, input$Lat1),
ncol = 2, byrow = TRUE)
shp <- SpatialPolygons(list(Polygons(list(Polygon(bbox)), ID = "a")), proj4string = CRS("+proj=longlat +ellps=WGS84 +datum=WGS84 +nodefs"))
} else {
shp_data <- unzip_shp()
shp <- shp_data$shp
}
period <- input$crop_year_in[1]:input$crop_year_in[2]
meta <- get_gcm_files(get_choices_crop(),
baseline = NULL,
projection = period,
model = input$mod_filter_in_crop,
scenario = input$scen_filter_in_crop,
ensemble = input$run_filter_in_crop)
request <- list(meta = meta,
shp = jsonlite::base64_enc(serialize(shp, NULL)),
period = period,
email = input$crop_email)
submission <- crop_req(request)
if (submission) {
showModal(modalDialog(title = "Request submitted",
sprintf("You data request has been submitted successfully. You should receive an email at %s soon. If you do not receive an email, please report the issue to https://gitlab.com/ConorIA/conjuntool/issues", input$crop_email)))
} else {
xmin <- isolate(min(input$Lon1, input$Lon2))
xmax <- isolate(max(input$Lon1, input$Lon2))
ymin <- isolate(min(input$Lat1, input$Lat2))
ymax <- isolate(max(input$Lat1, input$Lat2))
shp <- extent(c(xmin, xmax, ymin, ymax))
showModal(modalDialog(title = "Request failed",
sprintf("Unfortunately there was an error submitting your request. Please report the issue to https://gitlab.com/ConorIA/conjuntool/issues", input$crop_email)))
}
saveRDS(list(key, shp), "debug.rds")
crop_req(key, shp)
})
output$cropped_data_link = renderUI({
link <- get_crop_link()
link <- paste0(plumber_address, "download?filepath=", link)
tags$a(href = link, target = "blank", "Click here for your data")
})
output$source <- renderUI({
......
......@@ -52,7 +52,7 @@ dashboardPage(
box(
title = "Input",
width = 3,
selectInput("var_filter_plot", "Select Variables", c("tas", "tasmax", "tasmin", "pr", "prc")),
selectInput("var_filter_plot", "Select Variable", c("tas", "tasmax", "tasmin", "pr", "prc")),
checkboxInput("convert_units_plot",
HTML("Convert <b>K</b> to <b>&#176;C</b> OR <b>kg&#8901;m<sup>-2</sup>&#8901;s<sup>-1</sup></b> to <b>mm</b>."),
TRUE),
......@@ -81,7 +81,7 @@ dashboardPage(
# The id lets us use input$tabset1 on the server to find the current tab
id = "tabset1", width = 3,
tabPanel("1: Def. params",
selectInput("var_filter", "Select Variables", c("tas", "tasmax", "tasmin", "pr", "prc")),
selectInput("var_filter", "Select Variable", c("tas", "tasmax", "tasmin", "pr", "prc")),
checkboxInput("convert_units",
HTML("Convert <b>K</b> to <b>&#176;C</b> OR <b>kg&#8901;m<sup>-2</sup>&#8901;s<sup>-1</sup></b> to <b>mm</b>."),
TRUE),
......@@ -166,19 +166,31 @@ dashboardPage(
box(width = 2,
uiOutput("latlon_checkbox"),
uiOutput("crop_action"),
conditionalPanel("input.crop_go", htmlOutput("cropped_data_link"))
conditionalPanel("input.crop_go")
)
),
fluidRow(
box(
width = 3,
selectInput("var_filter_crop", "Select Variables", c("tas", "tasmax", "tasmin", "pr")),
singleModelSelectInput("crop_model_in", "Select Model")
#sliderInput("crop_year_in", label = "Years to Include", min = 2006,
# max = 2100, value = c(2011, 2100), step = 1,
# round = FALSE, sep = "", ticks = FALSE)
),
height = 7,
tabBox(
# The id lets us use input$tabset1 on the server to find the current tab
id = "tabset2", width = 3, height = "45vh",
tabPanel("1: Basics",
selectInput("var_filter_crop", "Select Variables", c("tas", "tasmax", "tasmin", "pr")),
uiOutput("ScenarioFilter_crop"),
sliderInput("crop_year_in", label = "Years to Include", min = 1850,
max = 2100, value = c(1981, 2100), step = 1,
round = FALSE, sep = "", ticks = FALSE),
textInput("crop_email", "Enter your email to receive data")
),
tabPanel("2: Models",
HTML("Choose the models from which to crop data.<br/><br/>"),
uiOutput("ModFilter_crop"), style = "max-height: 40vh; overflow-y: auto;"
),
tabPanel("3: Runs",
HTML("Choose ensembles / runs. Some models may not have output for all ensembles / runs.<br/><br/>"),
uiOutput("RunFilter_crop"), style = "max-height: 40vh; overflow-y: auto;"
)
),
box(
width = 9,
leafletOutput("resample_map")
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment