R/app.R

Defines functions KarstID

Documented in KarstID

#' Launch the KarstID shiny application
#' 
#' An implementation of common analyses of karst spring hydrographs
#' through a Shiny application. It includes recession curves, statistical, 
#' classified discharges and simple correlational and spectral analyses. The 
#' application also allows performing a classification of the hydrological 
#' functioning and comparing the results to a database of 78 karst systems.
#' 
#' @param ... No argument are needed to launch the application.
#'
#' @export
#' @import shiny
#' @import waiter
#' @import data.table
#' @import ggplot2
#' @importFrom magrittr %>%
#' @importFrom stats qnorm quantile sd
#' @importFrom utils write.table

KarstID <- function(...) {
  
options(shiny.maxRequestSize = 100*1024^2) # increase the upload limit file size to 100
  
file_format <- c("text/csv","text/comma-separated-values, text/plain", ".csv")
delim <- c("Tabulation", "Semicolon", "Comma", "Space")
day_hour <- c("Day", "Hour")
dec_mark <- c("Point", "Comma")
date_format <- "%Y-%m-%d"

ui <- fluidPage(
  shinyjs::useShinyjs(),
  shinyFeedback::useShinyFeedback(),
  waiter::useWaiter(),
  waiter::waiterPreloader(),
  
  navbarPage(
    "KarstID",
    id = "menu",
    
    tabPanel(
      "Data import",
      
      sidebarLayout(
        
        sidebarPanel(
          
          fileInput("import", "Import dataset", accept = file_format),
          
          fluidRow(
            
            column(6,
                   textInput("name", "Name")),
            column(6,
                   selectInput("time_step", "Time step", choices = day_hour))
          ),
          
          fluidRow(
            
            column(6,
                   numericInput("skip_row", "Skip row", value = 0, min = 0, step = 1)),
            column(6,
                   numericInput("sheet", "Sheet", value = 1, min = 1, step = 1))
          ),
          
          
          fluidRow(
            
            column(6,
                   radioButtons("dec_mark", "Decimal mark", choices = dec_mark)),
            column(6,
                   radioButtons("delim", "Delimiter", choices = delim))
          ),
          
          checkboxInput("header", "Header", value = TRUE),
          shinyjs::hidden(checkboxInput("data_mean", "Compute and use daily mean", value = FALSE)),
          textInput("date_format", "Date format", value = date_format) %>% 
            shinyhelper::helper(type = "inline",
                                title = "Date Format",
                                colour = "grey",
                                content = c("<b>%d</b> - Day as a number (0-31)",
                                            "<b>%m</b> - Month (00-12)",
                                            "<b>%y</b> - 2-digit year",
                                            "<b>%Y</b> - 4-digit year",
                                            "<b>%H</b> - Decimal hour (24 hour)",
                                            "<b>%M</b> - Decimal minute (0-59)",
                                            "<b>%S</b> - Decimal second (0-59)",
                                            "<b>%a</b> - Abbreviated weekday (e.g. Mon)",
                                            "<b>%A</b> - Unabbreviated weekday (e.g. Monday)",
                                            "<b>%b</b> - Abbreviated month (e.g. Jan)",
                                            "<b>%B</b> - Abbreviated month (e.g. January)",
                                            "<br>",
                                            "Most common formats:",
                                            "- Date: %Y-%m-%d",
                                            "- Datetime: %Y-%m-%d %H:%M:%S"),
                                size = "m",
                                buttonLabel = "OK"),
          numericInput("max_gap", "Max interpolation gap size", value = 5, min = 0, step = 1),
          checkboxInput("keep_na", "Keep NA values", value = TRUE),
          actionButton("load_import", "Load dataset"),
          actionButton("load_default", "Load test dataset"),
          shinyjs::disabled(downloadButton("download_dataset", "Download dataset"))
        ),
        
        mainPanel(
          
          plotOutput("import_plot"),
          DT::DTOutput("stats_indicator"),
          br(),
          shinyjs::hidden(downloadButton("dl_stats", "Download results"))
        )
      )
    ),
    
    tabPanel(
      "Recession curves analysis",
      
      fluidRow(
        
        column(8,
               plotOutput("rc_plot", brush = "rc_brush"),
               
               fluidRow(
                 
                 column(4,
                        align = "center",
                        uiOutput("ui_rc_slider"),
                        fluidRow(actionButton("zoom_rc", "Zoom"),
                                 actionButton("reset_rc", "Reset"),
                                 actionButton("add_rc", "Add"),
                                 actionButton("delete_rc", "Delete"))),
                 column(4,
                        align = "center",
                        br(), br(),
                        shinyjs::hidden(downloadButton("dl_rc", "Download selected recession")),
                        br(), br(),
                        shinyjs::hidden(downloadButton("dl_rt", "Download table"))),
                 column(4,
                        fileInput("ul_rc", "Upload KarstID recession workspace", accept = file_format),
                        shinyjs::hidden(downloadButton("dl_hydrofile", "Save KarstID recession workspace")))
                 
               ),
               
               br(), br(),
               DT::DTOutput("dt_recap")
        ),
        
        column(4,
               plotOutput("rc_model_plot", click = "rc_model_bp"),
               uiOutput("ui_napeak"),
               shinyjs::hidden(uiOutput("ui_bp_value")),
               shinyjs::hidden(actionButton("save_param", "Save indicators")),
               shinyjs::hidden(actionButton("clear_param", "Clear selection")),
               br(), br(),
               verbatimTextOutput("model_perf")
        )
      )
    ),
    
    tabPanel(
      "Simple correlational and spectral analyses",
      
      column(6, plotOutput("acf_plot")),
      column(6, plotOutput("spf_plot")),
      
      fluidRow(
        
        column(4,
               uiOutput("ui_acspf_slider")),
        
        column(4,
               br(),
               verbatimTextOutput("display_acspf")),
        
        column(3,
               offset = 1,
               br(), br(), 
               shinyjs::hidden(checkboxInput("spf_log", "Logarithmic scale")),
               shinyjs::hidden(downloadButton("dl_acspf", "Download results")))
      )
    ),
    
    tabPanel(
      "Analysis of classified discharges",
      
      column(6, 
             plotOutput("fdc_plot_normal"),
             br(),
             shinyjs::hidden(downloadButton("dl_fdc_normal", "Download results"))),
      
      column(6, 
             plotOutput("fdc_plot_mangin"),
             br(),
             shinyjs::hidden(checkboxInput("fdc_mangin_log", "Logarithmic scale")),
             shinyjs::hidden(downloadButton("dl_fdc_mangin", "Download results"))),
    ),
    
    tabPanel(
      "Classification",
      
      fluidRow(
        
        column(5,
               imageOutput("classif_img", inline = TRUE)),
        
        column(5,
               offset = 1,
               
               fluidRow(
                 
                 column(4,
                        br(),
                        tags$h3("Indicators:"),
                        tagAppendAttributes(tags$h5(textOutput("indicator_txt")), # allow \n in text
                                            style = "white-space:pre-wrap;")),
                 
                 column(8,
                        br(),
                        tags$h3("Distance to class:"),
                        tagAppendAttributes(tags$h5(htmlOutput("class_distance")), # allow \n in text
                                            style = "white-space:pre-wrap;"))
                 
                 ),
               
               fluidRow(br(),
                        textOutput("classif_txt"))
        )
      ),
      
      hr(),
      
      fluidRow(
        
        column(5,
               plotly::plotlyOutput("scatter_classif_plot", height = "600px")),
        
        column(7,
               DT::DTOutput("dt_classif"))
        
        )
      ),
    
    tags$script(
      HTML("var header = $('.navbar > .container-fluid');
                              header.append('<div style=\"float:right; padding-top: 8px\"><button id=\"about\" type=\"button\" class=\"btn action-button\">About</button></div>')")
    ),
    
    tags$script(
      HTML("var header = $('.navbar > .container-fluid');
header.append('<div style=\"float:right\"><a href=\"http://karma-project.org/\"><img src=\"extdata/KARMA_logo.png\" style=\"float:right; height:43px; padding-top:8px; padding-right:5px;\"></a> </div>');
    console.log(header)")
    ),
    
    tags$script(
      HTML("var header = $('.navbar > .container-fluid');
header.append('<div style=\"float:right\"><a href=\"https://sokarst.org/\"><img src=\"extdata/SNOKARST_logo.png\" style=\"float:right; height:43px; padding-top:8px; padding-right:5px;\"></a> </div>');
    console.log(header)")
    )
    
  )
)

server <- function(input, output, session) {
  
  shinyhelper::observe_helpers(withMathJax = TRUE)
  
  # about popup
  
  observeEvent(input$about, {
    about_popup()
  })
  
  
  # last tab memory
  
  tab <- reactiveValues(last = "Data import",
                        current = "Data import")
  
  observeEvent(input$menu, {
    tab$last <- tab$current
    tab$current <- input$menu
  })
  
  # shinyjs --------------------------------------------------------------------   
  
  # hide download button if no dataset
  observe({
    if (!is.null(df$df)) {
      shinyjs::show("dl_stats")
      shinyjs::show("dl_rc")
      shinyjs::show("dl_rt")
      shinyjs::show("dl_hydrofile")
      shinyjs::show("dl_acspf")
      shinyjs::show("dl_fdc_normal")
      shinyjs::show("dl_fdc_mangin")
      shinyjs::show("spf_log")
      shinyjs::show("fdc_mangin_log")
    } else {
      shinyjs::hide("dl_stats")
      shinyjs::hide("dl_rc")
      shinyjs::hide("dl_rt")
      shinyjs::hide("dl_hydrofile")
      shinyjs::hide("dl_acspf")
      shinyjs::hide("dl_fdc_normal")
      shinyjs::hide("dl_fdc_mangin")
      shinyjs::hide("spf_log")
      shinyjs::hide("fdc_mangin_log")
    }
  })

  # hide data mean if time step is daily
  observeEvent(input$time_step, {
    if (input$time_step == "Day")
      shinyjs::hide("data_mean")
    else
      shinyjs::show("data_mean")
  })
  
  # hide recession model widget if no selection is selected
  observeEvent(input$dt_recap_rows_selected,
               ignoreNULL = FALSE, {
                 if (!is.null(input$dt_recap_rows_selected)) {
                   shinyjs::show("ui_bp_value")
                   shinyjs::show("save_param")
                   shinyjs::show("clear_param")
                 } else {
                   shinyjs::hide("ui_bp_value")
                   shinyjs::hide("save_param")
                   shinyjs::hide("clear_param")
                 }
               })
  
  # import data ----------------------------------------------------------------
  
  delim <- reactive({
    switch(input$delim,
           "Tabulation" = "\t",
           "Semicolon" = ";",
           "Comma" = ",",
           "Space" = " ")
  })
  
  dec_mark <- reactive({
    switch(input$dec_mark,
           "Point" = ".",
           "Comma" = ",")
  })
  
  time_step <- reactive({
    switch(input$time_step,
           "Day" = FALSE,
           "Hour" = TRUE)
  })
  
  data_mean <- reactive({
    if (input$data_mean) "day" else "default"
  })
  
  data_mean_num <- eventReactive(c(input$load_import, input$load_default), {
    if (input$data_mean == FALSE) {
      if (input$time_step == "Day") 1 else 24
    } else {
      1
    }
  })
  
  df <- reactiveValues()
  
  df_interp <- reactive({
    req(df$df)
    df$df
    })
  
  dt_stats <- reactive({
    q <- df_interp()$discharge
    dt <-  data.frame(Mean = mean(q, na.rm = TRUE),
                      Min = min(q, na.rm = TRUE),
                      Max = max(q, na.rm = TRUE),
                      Sd = sd(q, na.rm = TRUE),
                      Q10 = quantile(q, 0.1, na.rm = TRUE),
                      Q90 = quantile(q, 0.9, na.rm = TRUE)) %>% 
      dplyr::mutate(CV = (Sd / Mean) * 100,
                    SVC = Q90 / Q10) %>% 
      dplyr::mutate(dplyr::across(dplyr::everything(), round, 2)) %>% 
      # add unit with HTML and escape = FALSE
      # CAREFUL, if any changes in the number/order of variables,
      # you must change the names() in the output$dl_stats
      dplyr::rename("Mean<br>(m<sup>3</sup>.s<sup>-1</sup>)" = Mean,
                    "Min<br>(m<sup>3</sup>.s<sup>-1</sup>)" = Min,
                    "Max<br>(m<sup>3</sup>.s<sup>-1</sup>)" = Max,
                    "SD<br>(m<sup>3</sup>.s<sup>-1</sup>)" = Sd,
                    "Q10<br>(m<sup>3</sup>.s<sup>-1</sup>)" = Q10,
                    "Q90<br>(m<sup>3</sup>.s<sup>-1</sup>)" = Q90,
                    "CV<br>(%)" = CV) %>% 
      dplyr::mutate(`Number of NAs` = length(which(is.na(q))))
  })
  
  observeEvent(input$load_default, {
    df$df <- default_dataset
    
    if (df_rc$count > 0) {
      df_rc$recap <- df_rc$recap %>% dplyr::slice(0)
      df_rc$list <- list()
      df_rc$count <- 0
      DT::replaceData(dt_recap_proxy, df_rc$recap, rownames = FALSE)
    }
    
    shinyjs::enable("download_dataset") # enable download 
  })
  
  observeEvent(input$load_import, {
    req(!is.null(input$import))
    shinyFeedback::feedbackWarning("max_gap",
                                   input$max_gap > 10,
                                   "It is recommended to be cautious regarding the interpolation of a high number of consecutive NA values (>10), as it increases the probability of irrelevant estimations.")

    notif$acsp <- TRUE # reset acsp notif
    
    df$df <- import_data(input$import$datapath, 
                         mean = data_mean(),
                         delim = delim(), 
                         skip = input$skip_row,
                         header = input$header, 
                         na = c("", "NA"), 
                         decimal_mark = dec_mark(), 
                         date_time = time_step(),
                         date_format = input$date_format, 
                         maxgap = input$max_gap, 
                         no_NA = !input$keep_na,
                         sheet = input$sheet)
    
    if (df_rc$count > 0) {
      df_rc$recap <- df_rc$recap %>% dplyr::slice(0)
      df_rc$list <- list()
      df_rc$count <- 0
      DT::replaceData(dt_recap_proxy, df_rc$recap, rownames = FALSE)
    }
    
    shinyjs::enable("download_dataset") # enable download 
    
    miss_date <- any(is.na(df$df$date)) # check date error
    shinyFeedback::feedbackDanger("date_format", 
                                  miss_date, 
                                  "Date format error. Consider reformatting date format or check your date input for eventual NAs.")
    
    req(miss_date, cancelOutput = TRUE)
    df$df <- NULL # if date error reset table
  })
  
  output$import_plot <- renderPlot(
    ggplot(df_interp(), aes(date, discharge)) +
      geom_line(size = 0.8) +
      theme_bw() +
      xlab("Date") +
      ylab(expression("Discharge" ~(m^3~.s^-1))) +
      theme(axis.title = element_text(size = 16, color = "#2d2d2d"),
            axis.text = element_text(size = 14, color = "#2d2d2d"))
  )
  
  output$stats_indicator <- DT::renderDT({
    DT::datatable(dt_stats(),
                  rownames = FALSE,
                  selection = "none",
                  escape = FALSE,
                  options = list(dom = "t"))
  })
  
  output$download_dataset <- downloadHandler(
    filename = paste0(input$name, "_dataset.txt"),
    content = function(filename) {
      write.table(df_interp(), filename, sep = "\t", row.names = FALSE)
    },
  )
  
  output$dl_stats <- downloadHandler(
    filename = paste0(input$name, "_statistics.txt"),
    content = function(filename) {
      dt <- dt_stats()
      names(dt) <- c("Mean", "Min", "Max", "SD", "Q10", "Q90", "CV", "SVC", "Number of NAs")
      write.table(dt, filename, sep = "\t", row.names = FALSE)
    }
  )  
  
  # manual recession selection -------------------------------------------------
  
  df_filtered <- reactive({
    req(input$rc_slider) # avoid error due to plot loading faster than slider
    dplyr::filter(df_interp(), dplyr::between(date, input$rc_slider[1], input$rc_slider[2]))
  })
  
  rc_brush <- reactive({
    brushedPoints(df_filtered(), input$rc_brush)
  })
  
  rc_length <- reactive({
    nrow(rc_brush())
  })
  
  df_rc <- reactiveValues(list = list(),
                          save = list(), # duplicate rc for NA peak values
                          recap = data.frame("num" = integer(),
                                             "start" = as.Date(character()),
                                             "end" = as.Date(character()),
                                             "breakpoint" = integer(),
                                             "k" = double(),
                                             "i" = double(),
                                             "alpha" = double()),
                          count = 0)
  
  observeEvent(input$zoom_rc, {
    update_slider(session, "rc_slider", rc_brush())
    session$resetBrush("rc_brush")
  })
  
  observeEvent(input$reset_rc, {
    update_slider(session, "rc_slider", df_interp())
    session$resetBrush("rc_brush")
  })
  
  observeEvent(input$dt_recap_rows_selected, ignoreInit = TRUE, {
    freezeReactiveValue(input, "bp_value") # avoid rc_model_plot flicker when switching recessions
    updateCheckboxInput(session, "napeak", value = napeak$list[[input$dt_recap_rows_selected]])
    if (is.na(df_rc$recap[input$dt_recap_rows_selected, "breakpoint"])) {
      updateNumericInput(session,  "bp_value", value = "")
    } else {
      updateNumericInput(session, "bp_value", value = df_rc$recap[input$dt_recap_rows_selected, "breakpoint"])
    }
  })
  
  observeEvent(input$add_rc, {
    req(input$rc_brush)
    df_rc$count <- df_rc$count + 1
    df_rc$list[[df_rc$count]] <- rc_brush() %>% dplyr::mutate(t = 0:(rc_length() - 1))
    df_rc$recap[nrow(df_rc$recap) + 1,] <- list(df_rc$count,
                                                min(rc_brush()$date),
                                                max(rc_brush()$date),
                                                NA, # bp
                                                NA, # k
                                                NA, # i 
                                                NA) # alpha
    session$resetBrush("rc_brush")
    DT::replaceData(dt_recap_proxy,
                    df_rc$recap,
                    rownames = FALSE,
                    clearSelection = "none",
                    resetPaging = FALSE)
    
    df_rc$save[[df_rc$count]] <- df_rc$list[[df_rc$count]]
    napeak$list[[df_rc$count]] <- FALSE
  })
  
  observeEvent(input$delete_rc, ignoreNULL = FALSE, ignoreInit = TRUE, {
    req(input$dt_recap_rows_selected)
    df_rc$recap <- df_rc$recap[-input$dt_recap_rows_selected,]
    df_rc$list[[input$dt_recap_rows_selected]] <- NULL
    df_rc$recap$num[df_rc$recap$num > input$dt_recap_rows_selected] <- df_rc$recap$num[df_rc$recap$num > input$dt_recap_rows_selected] - 1
    df_rc$count <- df_rc$count - 1
    
    DT::replaceData(dt_recap_proxy, 
                    df_rc$recap, 
                    rownames = FALSE, 
                    resetPaging = FALSE)
    
    df_rc$save[[input$dt_recap_rows_selected]] <- NULL
    napeak$list[[input$dt_recap_rows_selected]] <- NULL
  })
  
  output$dl_rc <- downloadHandler(
    filename = paste0(input$name, "_recession_list_export.txt"),
    content = function(filename) {
      # the lapply function add a "num" column to differentiate each recession
      rc_list <- rbindlist(lapply(seq_along(df_rc$list), 
                                  function(i) dplyr::mutate(df_rc$list[[i]], num = i)
      )
      )
      write.table(rc_list, filename, sep = "\t", row.names = FALSE)
    }
  )
  
  output$dl_rt <- downloadHandler(
    filename = paste0(input$name, "_recession_table_export.txt"),
    content = function(filename) {
      write.table(df_rc$recap, filename, sep = "\t", row.names = FALSE)
    }
  )
  
  output$dl_hydrofile <- downloadHandler(
    filename = paste0(input$name, "_KarstID_export.rds"),
    content = function(filename) {
      rc_export <- list(df_rc$list, df_rc$recap, napeak$list, df_rc$save)
      saveRDS(rc_export, filename)
    }
  )
  
  observeEvent(input$ul_rc, {
    rc_import <- readRDS(input$ul_rc$datapath)
    df_rc$list <- rc_import[[1]]
    df_rc$recap <- rc_import[[2]]
    napeak$list <- rc_import[[3]]
    df_rc$save <- rc_import[[4]]
    df_rc$count <- max(df_rc$recap$num, na.rm = TRUE)
    DT::replaceData(dt_recap_proxy, 
                    df_rc$recap, 
                    rownames = FALSE, 
                    resetPaging = FALSE)
  })
  
  output$ui_rc_slider <- renderUI({
    sliderInput(
      "rc_slider",
      "Select a time interval",
      min = min(df_interp()$date),
      max = max(df_interp()$date),
      value = c(min(df_interp()$date), max(df_interp()$date)),
      timeFormat = "%Y-%m",
    )
  })
  
  output$rc_plot <- renderPlot({
    req(input$rc_slider)
    plot_all_rc(
      df_filtered(),
      df_rc$list,
      input$rc_slider[1],
      input$rc_slider[2],
      input$dt_recap_rows_selected
    )
  }) %>% bindCache(df_filtered(), df_rc$list, input$dt_recap_rows_selected, input$rc_slider)
  
  output$dt_recap <- DT::renderDT({
    DT::datatable(isolate(df_rc$recap),
                  selection = list(mode = "single"),
                  rownames = FALSE,
                  options = list(dom = "tp"))
  })
  
  dt_recap_proxy <- DT::dataTableProxy("dt_recap")
  
  # recession model ---------------------------------------------------------
  
  napeak <- reactiveValues(list = list())
  
  qmean <- reactive(mean(df_interp()$discharge, na.rm = TRUE))
  
  vtransit <- reactive(qmean() * 86400 * 365)
  
  selected_recession <- reactive({
    # long format to allow input$rc_model_bp on rc_model_plot when model is up
    data.table::melt(as.data.table(df_rc$list[[input$dt_recap_rows_selected]]), id.vars = "t", measure.vars = "discharge")
  }) %>% 
    bindCache(df_rc$list[[input$dt_recap_rows_selected]]) %>% 
    bindEvent(input$dt_recap_rows_selected, input$napeak)
  
  mangin_model <- reactive({
    model_mangin(selected_recession(), input$bp_value, vtransit(), isolate(data_mean_num()))
  }) %>% 
    bindCache(selected_recession(), input$bp_value, vtransit(), isolate(data_mean_num())) %>% 
    bindEvent(input$bp_value, input$dt_recap_rows_selected, input$napeak) # can work with eventReactive instead of caching
  
  observeEvent(input$napeak, {
    req(input$dt_recap_rows_selected)
    napeak$list[input$dt_recap_rows_selected] <- input$napeak
    if (input$napeak) {
      df_rc$list[[input$dt_recap_rows_selected]] <- rm_peak(df_rc$list[[input$dt_recap_rows_selected]])
    }
    if (!input$napeak) {
      df_rc$list[[input$dt_recap_rows_selected]] <- df_rc$save[[input$dt_recap_rows_selected]]
    }
    
    shinyFeedback::hideFeedback("bp_value")
    
    max_bp_value <- max_bp_value(df_rc$list[[input$dt_recap_rows_selected]]$discharge)
    is_possible <- !(input$bp_value < 2 | 
                       input$bp_value >= max_bp_value)
    shinyFeedback::feedbackWarning("bp_value", 
                                   !is_possible, 
                                   paste0("Breakpoint must be numeric, greater than 1 and lower than ",
                                          max_bp_value, 
                                          "."))
  })
  
  observeEvent(input$rc_model_bp, {
    shinyFeedback::hideFeedback("bp_value")
    
    mangin_breakpoint <- nearPoints(selected_recession(), input$rc_model_bp, maxpoints = 1, threshold = 500)
    
    
    max_bp_value <- max_bp_value(df_rc$list[[input$dt_recap_rows_selected]]$discharge)
    is_possible <- !(input$bp_value < 2 | 
                       input$bp_value >= max_bp_value)
    shinyFeedback::feedbackWarning("bp_value", 
                                   !is_possible, 
                                   paste0("Breakpoint must be numeric, greater than 1 and lower than ",
                                          max_bp_value, 
                                          "."))
    
    
    updateNumericInput(session, "bp_value", value = mangin_breakpoint$t)
  })
  
  observeEvent(input$bp_value, {
    req(input$dt_recap_rows_selected)
    shinyFeedback::hideFeedback("bp_value")
    req(is.numeric(input$bp_value))
    max_bp_value <- max_bp_value(df_rc$list[[input$dt_recap_rows_selected]]$discharge)
    is_possible <- !(input$bp_value < 2 | 
                       input$bp_value >= max_bp_value)
    shinyFeedback::feedbackWarning("bp_value", 
                                   !is_possible, 
                                   paste0("Breakpoint must be numeric, greater than 1 and lower than ",
                                          max_bp_value, 
                                          "."))
  })
  
  observeEvent(input$save_param, {
    shinyFeedback::hideFeedback("bp_value")
    
    max_bp_value <- max_bp_value(df_rc$list[[input$dt_recap_rows_selected]]$discharge)
    is_possible <- !(input$bp_value < 2 | 
                       input$bp_value >= max_bp_value |
                       !is.numeric(input$bp_value))
    shinyFeedback::feedbackDanger("bp_value", 
                                  !is_possible, 
                                  paste0("Breakpoint must be numeric, greater than 1 and lower than ",
                                         max_bp_value, 
                                         "."))
    
    req(is_possible, cancelOutput = TRUE) 
    df_rc$recap[input$dt_recap_rows_selected, "breakpoint"] <- input$bp_value
    df_rc$recap[input$dt_recap_rows_selected, "k"] <- mangin_model()$k
    df_rc$recap[input$dt_recap_rows_selected, "i"] <- mangin_model()$i
    df_rc$recap[input$dt_recap_rows_selected, "alpha"] <- mangin_model()$alpha
    DT::replaceData(
      dt_recap_proxy,
      df_rc$recap,
      rownames = FALSE,
      clearSelection = "none",
      resetPaging = FALSE)
  })
  
  observeEvent(
    input$clear_param,
    {
      freezeReactiveValue(input, "bp_value")
      shinyFeedback::hideFeedback("bp_value")
      
      df_rc$recap[input$dt_recap_rows_selected, "breakpoint"] <- NA
      df_rc$recap[input$dt_recap_rows_selected, "k"] <- NA
      df_rc$recap[input$dt_recap_rows_selected, "i"] <- NA
      df_rc$recap[input$dt_recap_rows_selected, "alpha"] <- NA
      updateNumericInput(session, "bp_value", value = "")
      DT::replaceData(
        dt_recap_proxy,
        df_rc$recap,
        rownames = FALSE,
        clearSelection = "none",
        resetPaging = FALSE)
    })
  
  output$ui_napeak <- renderUI({
    req(input$dt_recap_rows_selected)
    checkboxInput(
      "napeak",
      "Remove spikes in the recession curve",
      # isolate to avoid infinite loop when d-click input$napeak
      value = FALSE
    )
  })
  
  output$ui_bp_value <- renderUI({
    numericInput(
      "bp_value", 
      "Breakpoint value",
      min = 0,
      max = 100000,
      value = "",
      step = 1)
  })
  
  output$rc_model_plot <- renderPlot({ 
    req(input$dt_recap_rows_selected, length(input$bp_value) > 0)
    plot_rc_model(selected_recession(), mangin_model()[["recession"]], input$bp_value)
  })
  
  output$model_perf <- renderText({
    req(input$dt_recap_rows_selected, input$bp_value)
    results <- mangin_model()$recession
    rmse <- rmse(results$discharge, results$sim_discharge)
    paste0("RMSE = ", round(rmse, 4), " m3/s")
  })
  
  # Simple correlational and spectral analyses -------------------------------------

  ascp_results <- reactive({
    req(isolate(input$menu) == "Simple correlational and spectral analyses")
    
    if (any(is.na(df_interp()$discharge))) {
      req(!notif$acsp)
      acsp_waiter$show()
      no_na_df <- fill_gap(df$df, maxgap = isolate(input$max_gap), no_NA = TRUE)
      acsp <- acspf(no_na_df$discharge, 
                    max_lag = input$acspf_slider, 
                    timestep = isolate(data_mean_num()))
      return(acsp)
    }
    
    if (!any(is.na(df_interp()$discharge))) {
      acsp_waiter$show()
      acsp <- acspf(df_interp()$discharge, 
                    max_lag = input$acspf_slider, 
                    timestep = isolate(data_mean_num()))
      return(acsp)
    }
  }) %>% 
    bindCache(df$df, df_interp(), notif$acsp, input$acspf_slider, isolate(input$max_gap), isolate(data_mean_num()))
  
  observeEvent(input$menu, {
    req(input$menu == "Simple correlational and spectral analyses")
    req(any(is.na(df_interp()$discharge)))
    req(notif$acsp)
    showModal(
      modalDialog(
        paste0("Do you want to perform simple correlational and spectral analyses on the longest non-NA part of the discharge time series?",
               " Current max gap \ninput is ",
               input$max_gap,
               ". Consider unchecking the `keep NA values` box and reload dataset in the import tab."),
        title = "Presence of one or several NA values in the discharge time series",
        footer = tagList(
          actionButton("perform_acspf", "Perform analysis"),
          actionButton("cancel_acspf", "Cancel")
        ),
        fade = FALSE
      )
    )
  })
  
  observeEvent(input$perform_acspf, {
    notif$acsp <- FALSE
    removeModal()
  })
  
  observeEvent(input$cancel_acspf, {
    removeModal()
    updateTabsetPanel(session, "menu", selected = tab$last)
  })
  
  max_cutting <- eventReactive(c(input$load_import, input$load_default), {
    freezeReactiveValue(input, "acspf_slider")
    round((nrow(df_interp()) / 3) / data_mean_num())
  })
  
  output$ui_acspf_slider <- renderUI({
    sliderInput(
      "acspf_slider", 
      "Define cutting point in days (m)", 
      value = 125,
      min = 2, 
      max = max_cutting(),
      step = 1)
  })
  
  output$dl_acspf <- downloadHandler(
    filename = paste0(input$name, "_simple_correlational_and_spectral_export.txt"),
    content = function(filename) {
      acspf_tab <- data.frame(k = ascp_results()$k,
                              rk = ascp_results()$rk,
                              f = c(NA, ascp_results()$f),
                              sf = c(NA, ascp_results()$sf))
      write.table(acspf_tab, filename, sep = "\t", row.names = FALSE)
    }
  )
  
  output$acf_plot <- renderPlot({
    plot_acf(ascp_results()$k, ascp_results()$rk)
  })
  
  output$spf_plot <- renderPlot({
    plot_spf(ascp_results()$f, ascp_results()$sf, log = input$spf_log)
  }) 
  
  output$display_acspf <- renderText({
    if (!is.na(ascp_results()$mem_ef))
      mem_ef_txt <- paste0("Memory Effect = ", round(ascp_results()$mem_ef, 2), " days \n")
    else
      mem_ef_txt <- paste0("Memory Effect cannot be calculated. \n")
    
    if (!is.na(ascp_results()$reg_time))
      reg_time_txt <- paste0("Regulation Time = ", round(ascp_results()$reg_time, 2), " days")
    else
      reg_time_txt <- paste0("Regulation Time cannot be calculated.")
  
    paste0(mem_ef_txt, 
           reg_time_txt)
  })
  
  # analysis of classified discharges ---------------------------------------
  
  fdc_df_normal <- reactive({
    req(isolate(input$menu) == "Analysis of classified discharges")
    fdc_normal(df_interp()$discharge)
  })
  
  fdc_df_mangin <- reactive({
    req(isolate(input$menu) == "Analysis of classified discharges")
    fdc_mangin(df_interp()$discharge)
  })
  
  output$fdc_plot_normal <- renderPlot({
    plot_fdc(fdc_df_normal(), method = "normal")
  })
  
  output$fdc_plot_mangin <- renderPlot({
    plot_fdc(fdc_df_mangin(), method = "mangin", xlog = input$fdc_mangin_log)
  })
  
  output$dl_fdc_normal <- downloadHandler(
    filename = paste0(input$name, "_classified_discharges_normal_export.txt"),
    content = function(filename) {
      write.table(fdc_df_normal(), filename, sep = "\t", row.names = FALSE)
    }
  )  
  
  output$dl_fdc_mangin <- downloadHandler(
    filename = paste0(input$name, "_classified_discharges_mangin_export.txt"),
    content = function(filename) {
      write.table(fdc_df_mangin(), filename, sep = "\t", row.names = FALSE)
    }
  )
  # classification ----------------------------------------------------------

  kmax <- reactive({
    if (length(df_rc$recap$k) > 0 & sum(!is.na(df_rc$recap$k)) > 0) {
      max(df_rc$recap$k, na.rm = TRUE)
    } else {
      NA
    }
    })
  
  alphamean <- reactive({
    if (length(df_rc$recap$alpha) > 0 & sum(!is.na(df_rc$recap$alpha)) > 0) {
      mean(df_rc$recap$alpha, na.rm = TRUE)
    } else {
      NA
    }
    })
  
  ir <- reactive({
    if (length(df_rc$recap$i) > 0 & sum(!is.na(df_rc$recap$i)) > 0) {
      max(df_rc$recap$i, na.rm = TRUE) - min(df_rc$recap$i, na.rm = TRUE)
    } else {
      NA
    }
    })
  
  class <- reactive(class_system(kmax(), alphamean(), ir()))
  
  dist_to_system <- reactive({
    classif_bdd[, Distance := calc_syst_distance(alpha_mean, alphamean(),
                                                 k_max, kmax(),
                                                 IR, ir())]
    classif_bdd <- classif_bdd[order(Distance)] %>% 
      # add unit with HTML and escape = FALSE
      dplyr::rename("Distance<br>(%)" = Distance,
                    "k<sub>max</sub>" = k_max,
                    "&#x3B1;<sub>mean</sub><br>(day<sup>-1</sup>)" = alpha_mean,
                    "Mean<br>(m<sup>3</sup>.s<sup>-1</sup>)" = mean,
                    "CV<br>(%)" = cv,
                    "SVC" = svc,
                    "ME<br>(day)" = ME,
                    "RT<br>(day)" = RT)
      
  })
  
  observeEvent(class(), {
    req(notif$plotly_trace)
    plotly::plotlyProxy("scatter_classif_plot", session) %>% 
      plotly::plotlyProxyInvoke("deleteTraces", c(78, 79)) 
    
    notif$plotly_trace <- FALSE
  })
  
  observeEvent(
    c(input$dt_classif_rows_selected),
    ignoreNULL = FALSE,
    ignoreInit = TRUE, {
      row_df <- nrow(classif_data_plot) # for counting traces
      
      # class style
      class_system <- classif_data_plot$System
      selected_system <- dist_to_system()$System[input$dt_classif_rows_selected]
      color_class <- ifelse(class_system %in% selected_system, 
                            "#fff700",
                            "black")
      width_class <- ifelse(class_system %in% selected_system, 
                            2, 
                            1.3)
      
      # m style
      m_system <- m$System
      selected_system <- dist_to_system()$System[input$dt_classif_rows_selected]
      color_m <- ifelse(m_system %in% selected_system, 
                        "#fff700",
                        "#8c8c8c")
      
      plotly::plotlyProxy("scatter_classif_plot", session) %>% 
        plotly::plotlyProxyInvoke("restyle",
                          list(marker.line.color = color_class,
                               marker.line.width = width_class),
                          as.list(seq(0, row_df - 1, 1))) %>% 
        plotly::plotlyProxyInvoke("restyle",
                          list(line = list(color = color_m)),
                          row_df)
    })
  
  observeEvent(input$menu, {
    req(input$menu == "Classification")
    req(!is.na(class()))
    
    if (length(which(!is.na(df_rc$recap$i))) < 2 & kmax() >= 0 & alphamean() >= 0) {
      req(notif$classif_ir)
      notif$classif_ir <- FALSE
      show_popup("It is advised to select at least two recession curves for a relevant IR indicator.")
    }
    
    if ((kmax() < 0 | alphamean() < 0) & length(which(!is.na(df_rc$recap$i))) >= 2) {
      req(notif$classif_k)
      notif$classif_k <- FALSE
      show_popup("kmax or alphamean should be greater than 0.")
    }
    
    if (length(which(!is.na(df_rc$recap$i))) < 2 & (kmax() < 0 | alphamean() < 0)) {
      req(notif$classif_k_ir)
      notif$classif_k_ir <- FALSE
      show_popup("It is advised to select at least two recession curves for a relevant IR indicator, and kmax or alphamean should be greater than 0.")
    }
    
    req(!notif$plotly_trace)
    color <- dplyr::recode(class(),
                           `C1` = "#000000",
                           `C2` = "#009E73",
                           `C3` = "#e79f00",
                           `C4` = "#9ad0f3",
                           `C5` = "#0072B2",
                           `C6` = "#D55E00")
    plotly::plotlyProxy("scatter_classif_plot", session) %>% 
      plotly::plotlyProxyInvoke("addTraces",
                        list(x = c(kmax(), kmax()),
                             y = c(alphamean(), alphamean()), 
                             z = c(ir(), ir()),
                             inherit = FALSE,
                             mode = "markers",
                             marker = list(color = color, line = list(color = "red", width = 3)),
                             showlegend = FALSE,
                             type = "scatter3d")) %>% 
      plotly::plotlyProxyInvoke("addTraces",
                        list(x = c(kmax(), kmax()),
                             y = c(alphamean(), alphamean()), 
                             z = c(0, ir()), 
                             mode = "lines",
                             showlegend = FALSE,
                             inherit = FALSE,
                             line = list(width = 5, color = "red"),
                             type = "scatter3d"))
    
    notif$plotly_trace <- TRUE
  })
  
  output$classif_img <- renderImage(deleteFile = FALSE, {
    filename <- class_filename(class())
    list(src = filename, width = "100%", height = "auto")
  })
  
  output$classif_txt <- renderText({
    name <- ifelse(input$name == "", "The system", input$name)
    if (!is.na(class())) carac_system(name, class())
  })
  
  output$indicator_txt <- renderText({
    if (is.na(kmax()) & is.na(alphamean()) & is.na(ir()))
      paste0("Indicators cannot be calculated: \n" ,
             "- No recession curves have been selected \n",
             "- Mangin's model has not been applied \n",
             "- Recession indicators were not saved")
    else
      paste0("k max = ", round(kmax(), 3), "\n", 
             "alpha mean = ", round(alphamean(), 3), "\n",
             "IR = ", round(ir(), 3))
  })
  
  output$class_distance <- renderText({
    dist <- calc_class_distance(class(), kmax(), alphamean(), ir())
    paste0("C1 : ", msg_dist(dist[1]), "\n", 
           "C2 : ", msg_dist(dist[2]), "\n",
           "C3 : ", msg_dist(dist[3]), "\n",
           "C4 : ", msg_dist(dist[4]), "\n",
           "C5 : ", msg_dist(dist[5]), "\n",
           "C6 : ", msg_dist(dist[6]))
  })
  
  output$dt_classif <- DT::renderDT({
    DT::datatable(dist_to_system(),
                  rownames = FALSE,
                  escape = FALSE,
                  options = list(dom = "ftp", pageLength = 10),
                  selection = "multiple")
  })
  
  output$scatter_classif_plot <- plotly::renderPlotly({
    plotly::plot_ly() %>% 
      plotly::add_trace(data = classif_data_plot,
                x = ~k_max, 
                y = ~alpha_mean, 
                z = ~IR,
                color = ~System,
                colors = ~color,
                mode = "markers",
                showlegend = FALSE,
                type = "scatter3d") %>% 
      plotly::add_trace(data = m, 
                x = ~k_max, 
                y = ~alpha_mean, 
                z = ~IR, 
                mode = "lines",
                showlegend = FALSE,
                line = list(width = 1.5),
                type = "scatter3d") %>% 
      plotly::layout(
        legend = list(x = 1, 
                      y = 0.5, 
                      font = list(size = 20),
                      itemsizing = "constant"),
        scene = list(
          hovermode = FALSE,
          xaxis = list(type = "log",
                       title = "k<sub>max</sub>",
                       showspikes = FALSE,
                       dtick = 1),
          yaxis = list(type = "log",
                       title = paste0(intToUtf8(0x03B1L), "<sub>mean</sub>"),
                       showspikes = FALSE,
                       dtick = 1),
          zaxis = list(title = "IR",
                       showspikes = FALSE)))
  })

  # notif -------------------------------------------------------------------

  notif <- reactiveValues(acsp = TRUE, # avoid showing notif again if user decides to perform analysis
                          classif_k = TRUE,
                          classif_ir = TRUE,
                          classif_k_ir = TRUE,
                          plotly_trace = FALSE) 
  
  # waiter ------------------------------------------------------------------
  
  acsp_waiter <- Waiter$new(id = c("acf_plot", "spf_plot"),
                                    html = spin_3(), 
                                    color = transparent(.7))
  
  # pre load outputs in background ------------------------------------------
  
  outputOptions(output, "ui_rc_slider", suspendWhenHidden = FALSE, priority = 10)
  outputOptions(output, "dt_recap", suspendWhenHidden = FALSE, priority = 5)
  outputOptions(output, "scatter_classif_plot", suspendWhenHidden = FALSE, priority = 2)
  outputOptions(output, "dt_classif", suspendWhenHidden = FALSE, priority = 1)
  
}

shinyApp(ui, server, ...)

}
busemorose/KarstID documentation built on July 22, 2024, 11:53 a.m.