---
title: "Variance Stabilizing Normalization (VSN) Dashboard"
format:
  html:
    theme: cosmo
    toc: false
    page-layout: full
server: shiny
execute:
  echo: false
  warning: false
  message: false
---

```{r}
#| context: setup
library(shiny)
library(vsn)
library(DT)
library(ggplot2)
library(hexbin)

options(shiny.maxRequestSize = 500 * 1024^2)
```

```{r}
#| context: setup
read_input <- function(path, filename) {
  sep <- if (grepl("\\.csv$", filename, ignore.case = TRUE)) "," else "\t"
  read.table(
    path,
    sep = sep,
    header = TRUE,
    quote = "",
    comment.char = "",
    stringsAsFactors = FALSE,
    check.names = FALSE,
    fileEncoding = "UTF-8-BOM"
  )
}

find_intensity_columns <- function(df, prefix) {
  if (!nzchar(trimws(prefix))) return(character(0))
  names(df)[startsWith(names(df), prefix)]
}

build_matrix <- function(df, cols) {
  x <- as.matrix(df[, cols, drop = FALSE])
  storage.mode(x) <- "double"
  x[!is.finite(x) | x <= 0] <- NA_real_
  x
}

safe_filename_part <- function(value, fallback = "value") {
  value <- trimws(value)
  value <- gsub("[^[:alnum:]_-]+", "-", value)
  value <- gsub("^-+|-+$", "", value)
  if (!nzchar(value)) fallback else value
}

number_tag <- function(value) {
  value <- format(value, scientific = TRUE, trim = TRUE, digits = 15)
  value <- gsub("\\+", "", value)
  gsub("\\.", "p", value)
}

make_parameter_suffix <- function(input) {
  paste0(
    "vsn_calib-", safe_filename_part(input$calib),
    "_lts-", number_tag(input$lts_quantile),
    "_subsample-", input$subsample,
    "_minpoints-", input$min_data_points,
    "_factr-", number_tag(input$factr),
    "_pgtol-", number_tag(input$pgtol),
    "_maxit-", input$maxit,
    "_trace-", input$trace,
    "_ltsiter-", input$cvg_niter,
    "_cvgeps-", number_tag(input$cvg_eps)
  )
}

unique_result_names <- function(df_names, intensity_cols, timestamp) {
  base_vsn <- paste0("vsn_", intensity_cols)
  base_pow2 <- paste0("pow2_", intensity_cols)
  collision <- any(c(base_vsn, base_pow2) %in% df_names)

  if (collision) {
    list(
      vsn = paste0(base_vsn, "_", timestamp),
      pow2 = paste0(base_pow2, "_", timestamp),
      timestamped = TRUE
    )
  } else {
    list(vsn = base_vsn, pow2 = base_pow2, timestamped = FALSE)
  }
}
```

```{css}
.sidebar-panel {
  background: #f8f9fa;
  border-radius: 14px;
  padding: 1rem;
}
.sidebar-panel .btn {
  width: 100%;
  margin-bottom: 0.45rem;
}
.sidebar-panel .form-group {
  margin-bottom: 0.8rem;
}
.parameter-heading {
  margin-top: 1.2rem;
  margin-bottom: 0.7rem;
  font-weight: 600;
}
.dashboard-grid {
  display: grid;
  grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
  gap: 1.5rem;
  align-items: start;
}
.sidebar-column {
  position: sticky;
  top: 1rem;
  max-height: calc(100vh - 2rem);
  overflow-y: auto;
}
.main-panel {
  min-width: 0;
  align-self: start;
}
.main-panel > h2:first-child {
  margin-top: 0;
}
.summary-card {
  background: #f8f9fa;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 0.8rem 1rem;
  margin-bottom: 1rem;
}
.summary-card pre {
  margin: 0;
  white-space: pre-wrap;
}
@media (max-width: 900px) {
  .dashboard-grid {
    grid-template-columns: 1fr;
  }
  .sidebar-column {
    position: static;
    max-height: none;
  }
}
```

::: {.dashboard-grid}
::: {.sidebar-column}

```{r}
div(
  class = "sidebar-panel",
  h3("Options"),
  tags$p(
    "proteinGroups.txt, report.tsv, or CSV. Example ",
    tags$a(
      "proteinGroups.txt",
      href = "https://zenodo.org/records/14557756/files/proteinGroups.txt?download=1",
      target = "_blank"
    ),
    ", details in ",
    tags$a(
      "post",
      href = "https://fuzzylife.substack.com/p/proteomics-data-processing-with-maxquant",
      target = "_blank"
    )),
  fileInput(
    "file",
    "Upload",
    accept = c(".txt", ".tsv", ".csv")
  ),
  textInput("pattern", "Intensity column prefix", value = "LFQ"),
  actionButton("run", "Run VSN Normalization", class = "btn-success"),
  uiOutput("download_ui"),
  tags$hr(),
  tags$div(class = "parameter-heading", "VSN parameters"),
  selectInput(
    "calib",
    "Calibration mode",
    choices = c("affine", "none"),
    selected = "affine"
  ),
  numericInput(
    "lts_quantile",
    "LTS quantile",
    value = 0.9,
    min = 0.5,
    max = 1,
    step = 0.01
  ),
  numericInput(
    "subsample",
    "Subsample rows (0 = all)",
    value = 0,
    min = 0,
    step = 1000
  ),
  numericInput(
    "min_data_points",
    "Minimum data points per stratum",
    value = 0,
    min = 0,
    step = 1
  ),
  checkboxInput("verbose", "Print fitting progress", value = TRUE),
  tags$div(class = "parameter-heading", "Optimizer parameters"),
  numericInput(
    "factr",
    "L-BFGS-B factr",
    value = 5e7,
    min = 1,
    step = 1e6
  ),
  numericInput(
    "pgtol",
    "Projected-gradient tolerance",
    value = 2e-4,
    min = 0,
    step = 1e-5
  ),
  numericInput(
    "maxit",
    "Maximum optimizer iterations",
    value = 60000,
    min = 1,
    step = 1000
  ),
  numericInput(
    "trace",
    "Optimizer trace level",
    value = 0,
    min = 0,
    step = 1
  ),
  tags$div(class = "parameter-heading", "LTS convergence"),
  numericInput(
    "cvg_niter",
    "Maximum LTS iterations",
    value = 7,
    min = 1,
    step = 1
  ),
  numericInput(
    "cvg_eps",
    "LTS convergence epsilon",
    value = 0,
    min = 0,
    step = 1e-4
  )
)
```

:::
::: {.main-panel}

::: {.summary-card}
```{r}
uiOutput("summary")
```
:::

::: {.panel-tabset}

## Diagnostics

```{r}
plotOutput("meansd", height = "500px")
plotOutput("meansd2", height = "500px")
uiOutput("meansd_explainer")
```

## Raw Mean-SD

```{r}
plotOutput("raw_meansd", height = "500px")
plotOutput("raw_meansd2", height = "500px")
uiOutput("raw_meansd_explainer")
```

## Preview

```{r}
DT::dataTableOutput("table")
```

## Regression

```{r}
selectInput("sample", "Sample", choices = NULL)
plotOutput("regression", height = "550px")
```

## Histograms

```{r}
fluidRow(
  column(6, plotOutput("raw_histogram", height = "520px")),
  column(6, plotOutput("vsn_histogram", height = "520px"))
)
```

## Box plots

```{r}
fluidRow(
  column(6, plotOutput("raw_boxplot", height = "620px")),
  column(6, plotOutput("vsn_boxplot", height = "620px"))
)
```

:::
:::
:::

```{r}
#| context: server
rawData <- reactive({
  req(input$file)
  read_input(input$file$datapath, input$file$name)
})

observe({
  req(rawData())
  cols <- find_intensity_columns(rawData(), input$pattern)
  updateSelectInput(
    session,
    "sample",
    choices = cols,
    selected = if (length(cols)) cols[[1]] else character(0)
  )
})

vsnData <- eventReactive(input$run, {
  df <- rawData()
  intensity_cols <- find_intensity_columns(df, input$pattern)

  validate(
    need(length(intensity_cols) > 0, "No matching intensity columns found."),
    need(input$lts_quantile >= 0.5 && input$lts_quantile <= 1,
         "LTS quantile must be between 0.5 and 1."),
    need(input$subsample >= 0, "Subsample must be zero or greater."),
    need(input$min_data_points >= 0,
         "Minimum data points per stratum must be zero or greater."),
    need(input$factr > 0, "factr must be greater than zero."),
    need(input$pgtol >= 0, "pgtol must be zero or greater."),
    need(input$maxit >= 1, "Maximum iterations must be at least one."),
    need(input$cvg_niter >= 1, "Maximum LTS iterations must be at least one."),
    need(input$cvg_eps >= 0, "LTS convergence epsilon must be zero or greater.")
  )

  raw_matrix <- build_matrix(df, intensity_cols)
  all_na_rows <- rowSums(is.finite(raw_matrix)) == 0L
  validate(need(any(!all_na_rows), "All selected intensity values are missing."))

  optimpar <- list(
    factr = as.numeric(input$factr),
    pgtol = as.numeric(input$pgtol),
    maxit = as.integer(input$maxit),
    trace = as.integer(input$trace),
    cvg.niter = as.integer(input$cvg_niter),
    cvg.eps = as.numeric(input$cvg_eps)
  )

  normalized_matrix <- vsn::justvsn(
    raw_matrix,
    lts.quantile = as.numeric(input$lts_quantile),
    subsample = as.integer(input$subsample),
    verbose = isTRUE(input$verbose),
    calib = input$calib,
    minDataPointsPerStratum = as.integer(input$min_data_points),
    optimpar = optimpar
  )

  timestamp <- format(Sys.time(), "%Y%m%d_%H%M%S")
  result_names <- unique_result_names(
    names(df),
    intensity_cols,
    timestamp
  )
  colnames(normalized_matrix) <- result_names$vsn

  pow2_matrix <- 2^normalized_matrix
  colnames(pow2_matrix) <- result_names$pow2

  export_table <- cbind(
    df,
    as.data.frame(normalized_matrix, check.names = FALSE),
    as.data.frame(pow2_matrix, check.names = FALSE)
  )

  showNotification(
    paste0("Successfully normalized ", length(intensity_cols), " columns."),
    type = "message",
    duration = 5
  )

  list(
    raw = raw_matrix,
    vsn = normalized_matrix,
    pow2 = pow2_matrix,
    export = export_table,
    intensity_columns = intensity_cols,
    vsn_columns = result_names$vsn,
    pow2_columns = result_names$pow2,
    timestamped = result_names$timestamped,
    all_na_rows = sum(all_na_rows)
  )
})

output$download_ui <- renderUI({
  req(vsnData())
  downloadButton("download", "Download VSN Normalized Data")
})

output$download <- downloadHandler(
  filename = function() {
    req(input$file)
    uploaded_stem <- tools::file_path_sans_ext(input$file$name)
    prefix <- safe_filename_part(input$pattern, "intensity")
    paste0(
      uploaded_stem,
      "_",
      prefix,
      "_",
      make_parameter_suffix(input),
      ".txt"
    )
  },
  content = function(file) {
    write.table(
      vsnData()$export,
      file = file,
      sep = "\t",
      quote = FALSE,
      row.names = FALSE,
      na = ""
    )
  }
)

output$summary <- renderUI({
  req(rawData())
  df <- rawData()
  intensity_cols <- find_intensity_columns(df, input$pattern)
  lines <- list(tags$div(
    paste0("Rows: ", nrow(df), " | Columns: ", ncol(df),
           " | Matched intensity columns: ", length(intensity_cols))
  ))
  if (!is.null(vsnData())) {
    lines <- c(lines, list(tags$div(
      tags$strong(paste0("All-NA rows excluded from fitting: ", vsnData()$all_na_rows)),
      paste0(" | Timestamped result columns: ", vsnData()$timestamped)
    )))
  }
  do.call(tags$div, c(list(style = "font-family: monospace;"), lines))
})
```

```{r}
#| context: server
output$meansd <- renderPlot({
  req(vsnData())
  z <- vsn::meanSdPlot(vsnData()$vsn, plot = FALSE)
  print(z$gg + ggplot2::theme_bw())
})
output$meansd2 <- renderPlot({
  req(vsnData())
  z <- vsn::meanSdPlot(vsnData()$vsn, ranks = FALSE, plot = FALSE)
  print(z$gg + ggplot2::theme_bw())
})
output$raw_meansd <- renderPlot({
  req(vsnData())
  z <- vsn::meanSdPlot(log2(vsnData()$raw), plot = FALSE)
  print(z$gg + ggplot2::theme_bw())
})
output$raw_meansd2 <- renderPlot({
  req(vsnData())
  z <- vsn::meanSdPlot(log2(vsnData()$raw), ranks = FALSE, plot = FALSE)
  print(z$gg + ggplot2::theme_bw())
})
output$meansd_explainer <- renderUI({
  req(vsnData())
  tags$div(class="alert alert-info",
    tags$p(tags$strong(paste0("All-NA rows excluded from fitting: ", vsnData()$all_na_rows, ". ")),
      "The empty lower-rank region also reflects sparse rows with too few finite values to estimate a row standard deviation."))
})
output$raw_meansd_explainer <- renderUI({
  req(vsnData())
  tags$div(class="alert alert-warning", tags$strong("Raw-data mean-SD plots"),
    tags$p("These plots use log2(raw intensity) before VSN. Compare the running median with the transformed Diagnostics tab."),
    tags$p(tags$strong(paste0("All-NA rows excluded from fitting: ", vsnData()$all_na_rows, "."))))
})

sample_median_stats <- function(x) {
  medians <- apply(x, 2L, median, na.rm=TRUE)
  medians <- medians[is.finite(medians)]
  list(medians=medians, mad=mad(medians, center=median(medians)))
}
output$raw_histogram <- renderPlot({
  req(vsnData()); x <- log2(vsnData()$raw); st <- sample_median_stats(x)
  v <- as.numeric(x); v <- v[is.finite(v)]
  hist(v, breaks=60, col="#E69F00", border="white", xlab="log2(raw intensity)", ylab="Frequency",
       main=paste0("Raw log2 intensity distribution\nMAD of sample medians = ", format(st$mad,digits=4)))
  abline(v=st$medians,col="#8C5200",lty=3); abline(v=median(st$medians),col="black",lwd=2)
})
output$vsn_histogram <- renderPlot({
  req(vsnData()); x <- vsnData()$vsn; st <- sample_median_stats(x)
  v <- as.numeric(x); v <- v[is.finite(v)]
  hist(v, breaks=60, col="#0072B2", border="white", xlab="VSN-transformed intensity", ylab="Frequency",
       main=paste0("VSN-transformed distribution\nMAD of sample medians = ", format(st$mad,digits=4)))
  abline(v=st$medians,col="#003F66",lty=3); abline(v=median(st$medians),col="black",lwd=2)
})
output$raw_boxplot <- renderPlot({
  req(vsnData()); x <- log2(vsnData()$raw); st <- sample_median_stats(x)
  boxplot(x,las=2,outline=FALSE,col="#E69F00",border="#8C5200",ylab="log2(raw intensity)",
          main=paste0("Raw log2 intensities by sample\nMAD of sample medians = ",format(st$mad,digits=4)))
  points(seq_along(st$medians),st$medians,pch=21,bg="white",col="black")
})
output$vsn_boxplot <- renderPlot({
  req(vsnData()); x <- vsnData()$vsn; st <- sample_median_stats(x)
  boxplot(x,las=2,outline=FALSE,col="#0072B2",border="#003F66",ylab="VSN-transformed intensity",
          main=paste0("VSN-transformed intensities by sample\nMAD of sample medians = ",format(st$mad,digits=4)))
  points(seq_along(st$medians),st$medians,pch=21,bg="white",col="black")
})

output$regression <- renderPlot({
  req(vsnData(), input$sample)
  idx <- match(input$sample, colnames(vsnData()$raw))
  validate(need(!is.na(idx), "Please choose a sample."))

  x <- log2(vsnData()$raw[, idx])
  y <- vsnData()$vsn[, idx]
  keep <- complete.cases(x, y)
  x <- x[keep]
  y <- y[keep]
  validate(need(length(x) > 5, "Not enough valid observations."))

  fit <- lm(y ~ x)
  fit_summary <- summary(fit)
  plot(
    x,
    y,
    pch = 19,
    cex = 0.5,
    xlab = paste0("log2(", colnames(vsnData()$raw)[idx], ")"),
    ylab = vsnData()$vsn_columns[idx],
    main = paste("VSN vs log2:", input$sample)
  )
  abline(fit, col = "blue", lwd = 2)
  legend(
    "topleft",
    bty = "n",
    legend = c(
      paste0("R² = ", round(fit_summary$r.squared, 4)),
      paste0("Intercept = ", round(coef(fit)[1], 4)),
      paste0("Slope = ", round(coef(fit)[2], 4))
    )
  )
})

output$table <- DT::renderDataTable({
  req(vsnData())
  DT::datatable(
    vsnData()$export,
    options = list(pageLength = 20, scrollX = TRUE),
    rownames = FALSE
  )
})
```
