"
- }
- }'
- ))
- )
- ),
- # Diverging colors
- conditionalPanel(
- condition = "input.cmode=='diverging'",
- selectizeInput(
- inputId = "divergentcolor",
- label = "Colorscale",
- rev(c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu")),
- selected = "RdBu",
- options = list(render = I(
- '{
- option: function(item, escape) {
- return "
" + escape(item.value) + "
"
- }
- }'
- ))
- ),
- ),
- ),
- tabPanel(
- "Download plots",
- # Note to warn user that generating plots can take a while
- h3("Note:"),
- div("Generating plots can take a while. Please be patient."),
- tags$br(),
- # message displaying it only downloads non-legacy plots as html
- div("Only non-legacy plots are downloaded as html. Legacy plots are to be downloaded by right-clicking on the plot and selecting 'Save image as...'"),
- tags$br(),
- # Download button
- # download buttons for matrix 1, matrix 2, matrix 1 vs matrix 2 (ordered by 1) and matrix 1 vs matrix 2 (ordered by 2)
- # show actionbuttons for downloading plots
- actionBttn("action_download1", "Download matrix 1", icon("download")), tags$br(),
- actionBttn("action_download2", "Download matrix 2", icon("download")), tags$br(),
- actionBttn("action_download3", "Download matrix 1 vs matrix 2 (ordered by 1)", icon("download")), tags$br(),
- actionBttn("action_download4", "Download matrix 1 vs matrix 2 (ordered by 2)", icon("download")), tags$br(),
- bsAlert("alert"),
- # hide download buttons in conditional panel with false condition
- div(id = "downloadButtons", style = "height: 0px; overflow: hidden;",
- downloadButton("download1", "Download matrix 1", icon("download")),
- downloadButton("download2", "Download matrix 2", icon("download")),
- downloadButton("download3", "Download matrix 1 vs matrix 2 (ordered by 1)", icon("download")),
- downloadButton("download4", "Download matrix 1 vs matrix 2 (ordered by 2)", icon("download")),
- )
- #downloadButton("download", "Download plots", icon("download"), disabled = TRUE),
- )
- )
- ),
-
- # Show a plot of the generated distribution
- mainPanel(
- tabsetPanel(
- id = "outputPanel",
- tabPanel(
- "Instructions",
- div(id="myDiv"),
- h3("Correlation analysis tool"),
- div("The input should be provided in the form of an xlsx file.
- The file should contain the data in the following format:"),
- tags$table(
- tags$tr(
- tags$th(""), tags$th("bacteria1"), tags$th("bacteria2"),
- tags$th("bacteria3"), tags$th("bacteria4"), tags$th("bacteria5")
- ),
- tags$tr(
- tags$th("bacteria1"), tags$td("100"), tags$td("98.5"),
- tags$td("97.5"), tags$td("96.5"), tags$td("95.5")
- ),
- tags$tr(
- tags$th("bacteria2"), tags$td("98.5"), tags$td("100"),
- tags$td("98.5"), tags$td("97.5"), tags$td("96.5")
- ),
- tags$tr(
- tags$th("bacteria3"), tags$td("97.5"), tags$td("98.5"),
- tags$td("100"), tags$td("98.5"), tags$td("97.5")
- ),
- tags$tr(
- tags$th("bacteria4"), tags$td("96.5"), tags$td("97.5"),
- tags$td("98.5"), tags$td("100"), tags$td("98.5")
- ),
- tags$tr(
- tags$th("bacteria5"), tags$td("95.5"), tags$td("96.5"),
- tags$td("97.5"), tags$td("98.5"), tags$td("100")
- )
- ),
- div(
- strong("Note: "),
- "The diagonal should always be 100 since this is the same thing you are comparing."
- ),
- div(
- strong("Note: "),
- "Row and column names are free to choose however you always need the same values in both.
- Avoid spaces in these column and row names!"
- ),
- br(),
- tags$ol(
- tags$li("Upload the Excel file"),
- tags$li("Select the sheets containing the data. Note that the second sheet is optional. If provided, you can compare two correlation matrixes from different sheets."),
- tags$li("If two sheets are provided, you can change the labels for the X and Y axis for the combined plots. By default, the names of the sheets will be suggested."),
- tags$li("click on \"Visualize the dataset\". This can take a few seconds depending on the size of the dataset."),
- tags$li("To customize the plot, go to the tab \"Plot settings\" on the left and change the settings as you please.")
- ),
- br(),
- # advanced information about metadata, hidden in a collapsible panel
- bsCollapsePanel(
- #id = "advanced_info",
- # title of the panel, including a arrow symbol to indicate that it is collapsible
- title = tags$span(tags$i(class = "fa fa-arrow-right"), " Advanced information"),
- div(
- strong("Note: "),
- "The metadata should be provided in the same Excel file as the data but in a separate sheet. The sheet should contain the data in the following format:"
- # first column contains a sample id with the same name as the columns from the data sheet, the other columns contain the metadata with a header describing the metadata
- ),
- tags$table(
- tags$tr(
- tags$th("sample"), tags$th("metadata1"), tags$th("metadata2"),
- tags$th("...")
- ),
- tags$tr(
- tags$th("bacteria1"), tags$td("value1"), tags$td("value2"),
- tags$td("...")
- ),
- tags$tr(
- tags$th("bacteria2"), tags$td("value1"), tags$td("value2"),
- tags$td("...")
- ),
- tags$tr(
- tags$th("bacteria3"), tags$td("value1"), tags$td("value2"),
- tags$td("...")
- ),
- tags$tr(
- tags$th("bacteria4"), tags$td("value1"), tags$td("value2"),
- tags$td("...")
- ),
- tags$tr(
- tags$th("bacteria5"), tags$td("value1"), tags$td("value2"),
- tags$td("...")
- )
- ),
- div(
- strong("Note: "),
- "The first column should always contain the sample id. The name of this column is not important. The other columns can be named as you please."
- ),
- div(
- strong("Note: "),
- "The metadata sheet is optional. If you do not provide a metadata sheet, the plots will be generated without metadata."
- )
- ),
- actionButton("Samplefile", "Run the analysis on a sample file"),
- downloadButton("downloadSampleFile", "Download the sample file")
- ),
- tabPanel(
- title = "Matrix 1",
- conditionalPanel(
- condition = "input.legacymode==false",
- plotlyOutput("ANI", width = "100%", height = "80vh")
- ),
- conditionalPanel(
- condition = "input.legacymode==true",
- plotOutput("ANI_legacy", width = "100%", height = "80vh")
- )
- ),
- tabPanel(
- title = "Matrix 2",
- conditionalPanel(
- condition = "input.legacymode==false",
- plotlyOutput("MALDI", width = "100%", height = "80vh")
- ),
- conditionalPanel(
- condition = "input.legacymode==true",
- plotOutput("MALDI_legacy", width = "100%", height = "80vh")
- )
- ),
- tabPanel(
- title = "Matrix 1 vs Matrix 2 (ordered on matrix 1)",
- htmlOutput("ANIMALDI_Error"),
- conditionalPanel(
- condition = "input.legacymode==false",
- plotlyOutput("ANIMALDI", width = "100%", height = "80vh")
- ),
- conditionalPanel(
- condition = "input.legacymode==true",
- plotOutput("ANIMALDI_legacy", width = "100%", height = "80vh")
- )
- ),
- tabPanel(
- title = "Matrix 1 vs Matrix 2 (ordered on matrix 2)",
- htmlOutput("MALDIANI_Error"),
- conditionalPanel(
- condition = "input.legacymode==false",
- plotlyOutput("MALDIANI", width = "100%", height = "80vh")
- ),
- conditionalPanel(
- condition = "input.legacymode==true",
- plotOutput("MALDIANI_legacy", width = "100%", height = "80vh")
- )
- )
- )
- )
- )
-)
+# MAniR: matrix comparison and scalable visualization.
+ui <- shiny::fluidPage(
+ shiny::tags$head(
+ shiny::tags$meta(name = "viewport", content = "width=device-width, initial-scale=1"),
+ shiny::tags$style(shiny::HTML("
+ body { background: #f7f9fb; color: #213547; }
+ .well { background: #fff; border-color: #dae2ea; }
+ .main-panel { background: #fff; padding: 18px; border: 1px solid #dae2ea;
+ border-radius: 8px; }
+ .small-help { color: #56697a; font-size: .9em; margin-bottom: 10px; }
+ .plot-area { min-height: 520px; }
+ .metric { padding: 10px; margin: 6px 0; background: #f2f6f9;
+ border-left: 3px solid #487f9b; }
+ .tab-content { padding-top: 15px; }
+ "))
+ ),
+ shiny::titlePanel("MAniR"),
+ shiny::p("Explore and compare pairwise similarity, correlation or distance matrices."),
+ shiny::sidebarLayout(
+ shiny::sidebarPanel(width = 3,
+ shiny::h4("Input data"),
+ shiny::fileInput("first_file", "First matrix",
+ accept = c(".xlsx", ".xlsm", ".csv", ".tsv", ".txt",
+ ".gz", ".rds")),
+ shiny::uiOutput("first_sheet_ui"),
+ shiny::fileInput("second_file", "Second matrix file (optional)",
+ accept = c(".xlsx", ".xlsm", ".csv", ".tsv", ".gz", ".rds")),
+ shiny::uiOutput("second_sheet_ui"),
+ shiny::fileInput("metadata_file", "Separate metadata file (optional)",
+ accept = c(".xlsx", ".csv", ".tsv", ".rds")),
+ shiny::uiOutput("metadata_sheet_ui"),
+ shiny::fileInput("order_file", "Optional sample order (one ID per line)",
+ accept = c(".txt", ".csv", ".tsv")),
+ shiny::selectInput("kind1", "Matrix 1 represents",
+ choices = c("Similarity" = "similarity", "Correlation" = "correlation",
+ "Distance" = "distance", "Unspecified" = "auto")),
+ shiny::selectInput("kind2", "Matrix 2 represents",
+ choices = c("Similarity" = "similarity", "Correlation" = "correlation",
+ "Distance" = "distance", "Unspecified" = "auto")),
+ shiny::radioButtons("match_mode", "Compare samples",
+ choices = c("Require identical IDs" = "strict",
+ "Shared isolates only" = "intersection"),
+ selected = "strict"),
+ shiny::actionButton("visualize", "Load and analyze", class = "btn-primary"),
+ shiny::hr(),
+ shiny::h4("Visualization"),
+ shiny::checkboxInput("cluster", "Cluster samples", value = TRUE),
+ shiny::selectInput("palette", "Palette",
+ choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
+ "Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
+ selected = "RdBu"),
+ shiny::checkboxInput("show_numbers", "Display cell numbers in small plots",
+ value = FALSE),
+ shiny::checkboxInput("log_scale", "Logarithmic display scaling",
+ value = FALSE),
+ shiny::selectInput("metadata_column", "Categorical annotation track",
+ choices = c("None" = "")),
+ shiny::checkboxInput("comparable_scales",
+ "Both matrices use comparable numerical units", value = FALSE),
+ shiny::helpText("For large matrices, plots use representative pixels. Numerical analyses and exports use original values unless stated otherwise."),
+ shiny::hr(),
+ shiny::h4("Statistical analysis"),
+ shiny::numericInput("max_pairs", "Maximum plotted/analyzed pairs",
+ 100000, min = 1000, max = 1000000, step = 1000),
+ shiny::numericInput("cluster_k", "Clusters for concordance", 3,
+ min = 2, max = 100),
+ shiny::numericInput("permutations", "Mantel permutations", 999,
+ min = 99, max = 9999),
+ shiny::actionButton("run_mantel", "Run Mantel test"),
+ shiny::p(class = "small-help",
+ "Mantel tests require complete matrices and at most 400 isolates.")
+ ),
+ shiny::mainPanel(width = 9,
+ shiny::div(class = "main-panel",
+ shiny::tabsetPanel(id = "results_tab",
+ shiny::tabPanel("Overview",
+ shiny::uiOutput("overview"), shiny::tableOutput("agreement"),
+ shiny::verbatimTextOutput("mantel_result"),
+ shiny::tableOutput("replicate_table")),
+ shiny::tabPanel("Matrix 1",
+ shiny::uiOutput("plot1_ui"), shiny::verbatimTextOutput("plot1_cell")),
+ shiny::tabPanel("Matrix 2",
+ shiny::uiOutput("plot2_ui"), shiny::verbatimTextOutput("plot2_cell")),
+ shiny::tabPanel("Combined (order 1)",
+ shiny::uiOutput("combined1_ui"),
+ shiny::verbatimTextOutput("combined1_cell")),
+ shiny::tabPanel("Combined (order 2)",
+ shiny::uiOutput("combined2_ui"),
+ shiny::verbatimTextOutput("combined2_cell")),
+ shiny::tabPanel("Difference",
+ shiny::uiOutput("difference_ui"),
+ shiny::p("Only available when both matrices use comparable numerical units."),
+ shiny::verbatimTextOutput("difference_cell")),
+ shiny::tabPanel("Pairwise comparison",
+ shiny::plotOutput("scatter", height = "520px"),
+ shiny::tableOutput("discrepancies"),
+ shiny::downloadButton("download_pairs", "Download pairwise values (CSV)")),
+ shiny::tabPanel("Cluster comparison",
+ shiny::tableOutput("cluster_summary"),
+ shiny::tableOutput("cluster_table"),
+ shiny::downloadButton("download_clusters", "Download cluster assignments")),
+ shiny::tabPanel("Metadata",
+ shiny::tableOutput("metadata_preview"),
+ shiny::uiOutput("metadata_note")),
+ shiny::tabPanel("Export",
+ shiny::p("Original numeric matrices are exported without display normalization. Large matrix images are representative views."),
+ shiny::downloadButton("download_first", "Matrix 1 CSV"),
+ shiny::downloadButton("download_second", "Matrix 2 CSV"),
+ shiny::downloadButton("download_rds", "Analysis matrices (RDS)"),
+ shiny::downloadButton("download_first_png", "Matrix 1 PNG"),
+ shiny::downloadButton("download_combined_png", "Combined PNG"),
+ shiny::downloadButton("download_settings", "Analysis settings (text)")
+ )
+ )
+ )
+ )
+ )
+)
From 9e8793c544ee5faa64bd4c99fa5af58ab27bf056 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:21:42 +0200
Subject: [PATCH 007/134] feat: lazy matrix views, scoped session analysis,
scientific comparisons and exports
---
server.R | 1048 ++++++++++++++++++++++--------------------------------
1 file changed, 417 insertions(+), 631 deletions(-)
diff --git a/server.R b/server.R
index 37d2eba..ab72090 100644
--- a/server.R
+++ b/server.R
@@ -1,631 +1,417 @@
-library("shiny")
-library("shinyjs")
-library("shinyBS")
-library("shinyWidgets")
-library("openxlsx")
-library("RColorBrewer")
-library("corrplot")
-library("periscope")
-library("heatmaply")
-library("plotly")
-
-
-# Define server logic required to draw a histogram
-server <- function(input, output, session) {
-
- sendCustomMessage <- function(id,msg) {
- session$sendCustomMessage(id, msg)
- }
-
- debugStart <- function() {
- # click on Run tha analysis on a sample file button and then click on Visualize the results
- click("Samplefile")
- # click on visualise but do this in setTimeout so that the sample file is loaded first using shinyjs
- shinyjs::runjs("setTimeout(function(){
- $('#visualize').click();
- }, 500);")
- }
-
- # disable the tabPanels Matrix1, Matrix2
- shinyjs::js$disableTab("Matrix\ 1")
- shinyjs::js$disableTab("Matrix\ 2")
- #Matrix 1 vs Matrix 2 (ordered on matrix 1)
- shinyjs::js$disableTab("Matrix\ 1 vs Matrix\ 2\ (ordered\ on\ matrix\ 1)")
- #Matrix 1 vs Matrix 2 (ordered on matrix 2)
- shinyjs::js$disableTab("Matrix\ 1 vs Matrix\ 2\ (ordered\ on\ matrix\ 2)")
-
- #debugStart()
-
- getcolorscale <- function() {
- if (input$cmode == "sequential") {
- return(COL1(input$sequentialcolor, 200))
- } else {
- return(COL2(input$divergentcolor, 200))
- }
- }
-
- single_plot_legacy <- function(data) {
- par(
- mar = c(input$labelcex + 1, 4.1, 4.1, 2.1),
- xpd = TRUE
- )
- # single plot
- corrplot.mixed(
- data,
- lower = "color",
- lower.col = getcolorscale(),
- upper = "number",
- upper.col = getcolorscale(),
- is.corr = FALSE,
- order = "hclust",
- diag = "n",
- tl.col = "black",
- number.cex = input$ncex,
- tl.cex = input$tlcex,
- number.digits = input$dec,
- tl.pos = "lt",
- cl.cex = input$clcex
- )
- }
-
- single_plot <- function(data, metadata, file=NULL) {
- heatmaply(
- data,
- col = getcolorscale(),
- #dendrogram = input$dendrograms,
- # input$dec is the number of decimal places
- label_format_fun = function(...) format(..., digits = input$dec + 2),
- # add number to each cell also with input$dec for decimals shown and center it but only if show_numbers is TRUE
- cellnote = if (input$show_numbers) round(data, input$dec) else NULL,
- #cellnote = round(data, input$dec),
- cellnote_textposition = "middle center",
- # dendrogram only on the side
- show_dendrogram = c(input$dendrograms, FALSE),
- # add title if input$add_title is TRUE
- #main = if (input$add_title) input$title else "",
- # hide colorbar if input$hide_colorbar is TRUE
- hide_colorbar = input$hide_colorbar,
- # if metadata is not NULL, add it to the heatmap as side colors (row only)
- row_side_colors = if (!is.null(metadata)) metadata else NULL,
- key = TRUE,
- # if file is not NULL, save the heatmap to the file
- file = if (!is.null(file)) file else NULL,
- # use 1000 by 1000 if file is not NULL
- width = if (!is.null(file)) 1000 else NULL,
- height = if (!is.null(file)) 1000 else NULL,
- # input$ncex describe the size of the numbers
- cellnote_size = input$ncex * 10,
- # add a little bit of margin to the top
- margin = c(0, 0, 10, 0),
- )
- }
-
- combined_plot_legacy <- function(data1, data2) {
- par(
- mar = c(input$labelcex + 1, 4.1, 4.1, 2.1),
- oma = c(3 * input$labelcex, 0, 0, 3 * input$labelcex),
- xpd = TRUE
- )
- corrplot(
- data1,
- is.corr = FALSE,
- type = "upper",
- #order = "hclust",
- tl.col = "black",
- method = "color",
- col = getcolorscale(),
- addgrid.col = "grey",
- addCoef.col = "black",
- number.cex = input$ncex,
- number.digits = input$dec,
- diag = TRUE,
- tl.pos = "lt",
- tl.cex = input$tlcex,
- cl.cex = input$clcex
- )
- corrplot(
- data2,
- is.corr = FALSE,
- type = "lower",
- order = "original",
- tl.col = "black",
- method = "color",
- col = getcolorscale(),
- addgrid.col = "grey",
- addCoef.col = "black",
- number.cex = input$ncex,
- number.digits = input$dec,
- diag = FALSE,
- tl.cex = input$tlcex,
- tl.pos = "n",
- cl.pos = "b",
- cl.cex = input$clcex,
- add = TRUE
- )
-
- # Add axis titles
- mtext(
- input$yas,
- side = 4,
- outer = TRUE,
- line = input$labelcex,
- cex = input$labelcex
- )
- mtext(
- input$xas,
- side = 1,
- outer = TRUE,
- line = input$labelcex,
- cex = input$labelcex
- )
- }
-
- combined_plot <- function(data1, data2, order_x, order_y, metadata, file=NULL) {
- data1 <- as.data.frame(data1)
- data2 <- as.data.frame(data2)
- # reorder the data
- data1 <- data1[order_x, order_y]
- data2 <- data2[order_x, order_y]
- # make a copy of data1
- combined_data <- data1
- # replace lower triangle with data2
- combined_data[lower.tri(combined_data)] <- data2[lower.tri(data2)]
- combined_data <- as.matrix(combined_data)
-
- # create a copy of the data as original_data
- original_data <- combined_data
-
- if (input$logscale) {
- # scale upper triangle between 0 and 1
- combined_data[upper.tri(combined_data)] <- (log(combined_data[upper.tri(combined_data)]) - min(log(combined_data[upper.tri(combined_data)]))) / (max(log(combined_data[upper.tri(combined_data)])) - min(log(combined_data[upper.tri(combined_data)])))
- # scale lower triangle between 0 and 1
- combined_data[lower.tri(combined_data)] <- (log(combined_data[lower.tri(combined_data)]) - min(log(combined_data[lower.tri(combined_data)]))) / (max(log(combined_data[lower.tri(combined_data)])) - min(log(combined_data[lower.tri(combined_data)])))
- } else {
- # scale upper triangle between 0 and 1
- combined_data[upper.tri(combined_data)] <- (combined_data[upper.tri(combined_data)] - min(combined_data[upper.tri(combined_data)])) /
- (max(combined_data[upper.tri(combined_data)]) - min(combined_data[upper.tri(combined_data)]))
- # scale lower triangle between 0 and 1
- combined_data[lower.tri(combined_data)] <- (combined_data[lower.tri(combined_data)] - min(combined_data[lower.tri(combined_data)])) /
- (max(combined_data[lower.tri(combined_data)]) - min(combined_data[lower.tri(combined_data)]))
- }
- # make the diagonal 1
- diag(combined_data) <- 1
- # create custom hovertext. Include the original value and which matrix it came from (input$yas or input$xas)
- custom_text <- paste0(
- "Original value:",
- round(original_data, input$dec),
- "
",
- "Matrix:",
- ifelse(upper.tri(original_data), input$yas, input$xas)
- )
-
- # make it a matrix
- custom_text <- matrix(custom_text, nrow = nrow(original_data), ncol = ncol(original_data))
-
- # transpose the data (both combined_data and original_data)
- combined_data <- t(combined_data)
- original_data <- t(original_data)
-
- # flip rows of both
- combined_data <- combined_data[rev(1:nrow(combined_data)), ]
- original_data <- original_data[rev(1:nrow(original_data)), ]
-
- # same for custom_text
- custom_text <- t(custom_text)
- custom_text <- custom_text[rev(1:nrow(custom_text)), ]
-
-
- #print(custom_text)
-
- heatmaply(
- combined_data,
- col = getcolorscale(),
- # input$dec is the number of decimal places
- label_format_fun = function(...) format(..., digits = input$dec + 2),
- # add number to each cell also with input$dec for decimals shown and center it but only if show_numbers is TRUE
- cellnote = if (input$show_numbers) round(original_data, input$dec) else NULL,
- #cellnote = round(original_data, input$dec),
- cellnote_textposition = "middle center",
- # add the custom hovertext
- custom_hovertext = custom_text,
- # no dendrogram
- show_dendrogram = c(FALSE, FALSE),
- # do not reorder the rows or columns
- Rowv = order_x,
- Colv = order_y,
- #revC = TRUE,
- # add the axis titles
- xlab = input$xas,
- ylab = input$yas,
- # hide colorbar
- hide_colorbar = TRUE,
- # add the metadata if it exists
- row_side_colors = if (!is.null(metadata)) metadata else NULL,
- # title if input$add_title is TRUE
- #main = if (input$add_title) input$title else ""
- # size of numbers in cells is set by input$ncex
- cellnote_size = input$ncex*10,
- # add file name if file is not NULL
- file=file
- )
- }
- check_errors <- function(col1, col2, output_id) {
- if (!all(col1 %in% col2) || !all(col2 %in% col1)) {
- # The page has an output_id without the _Error suffix that normally displays the output plot.
- # We need to make sure this plot is not displayed when there is an error.
- output[[gsub("_Error", "", output_id)]] <- NULL
-
- not_in_matrix1 <- col2[!(col2 %in% col1)]
- not_in_matrix2 <- col1[!(col1 %in% col2)]
- output[[output_id]] <- renderUI({
- div(class = "alert alert-danger", role = "alert", {
- tagList(
- h3("Error: The columns of the two matrices are not the same. Please check your input."),
- # print the missing columns
- h4("Missing columns in Matrix 1:"),
- # print only the missing columns in Matrix 1, not all differences
- p(paste(not_in_matrix1, collapse = ", ")),
- br(),
- h4("Missing columns in Matrix 2:"),
- # print only the missing columns in Matrix 2, not all differences
- p(paste(not_in_matrix2, collapse = ", "))
- )
- })
- })
- return(FALSE)
- }
- return(TRUE)
- }
-
-
- output$downloadSampleFile <- downloadHandler(
- filename = function() {
- "sample_file.xlsx"
- },
- content = function(con) {
- file.copy("www/moc_data.xlsx", con)
- }
- )
-
- wb <- reactive({
- if (is.null(input$input_excel) == FALSE | input$Samplefile > 0) {
- if (!is.null(input$input_excel)) {
- loadWorkbook(input$input_excel$datapath)
- } else if (input$Samplefile > 0) {
- loadWorkbook("www/moc_data.xlsx")
- }
- } else {
- return(NULL)
- }
- })
-
-
- observeEvent(c(input$input_excel, input$Samplefile), {
- if (is.null(input$input_excel) == FALSE | input$Samplefile > 0) {
- sheets <- sheets(wb())
- # sheet selection boxes
- output$tab1 <- renderUI({
- selectInput(
- "sheet1",
- HTML("Worksheet with first correlation
matrix"),
- choices = sheets,
- selected = sheets[1]
- )
- })
- output$tab2 <- renderUI({
- selectInput(
- "sheet2",
- "Worksheet with second correlation matrix (optional)",
- choices = c(sheets, "None"),
- selected = sheets[min(2, length(sheets))]
- )
- })
- # metadata tab
- output$tab3 <- renderUI({
- selectInput(
- "sheet3",
- "Worksheet with metadata (optional)",
- choices = c(sheets, "None"),
- selected = if (length(sheets) > 2) sheets[3] else "None"
- )
- })
-
- # Xaxis and Yaxis selection boxes + auto suggestion based on sheet names
- output$Yaxis <- renderUI({
- textInput("yas", label = "Name for matrix 1 (Y-axis on combined plot)", value = sheets[1])
- })
- output$Xaxis <- renderUI({
- textInput("xas", label = "Name for matrix 2 (X-axis on combined plot)", value = sheets[min(2, length(sheets))])
- })
- }
- })
-
- observeEvent(input$sheet2, {
- # if no second sheet is selected, don't use Xaxis/Yaxis
- if (input$sheet2 == "None") {
- output$Xaxis <- NULL
- output$Yaxis <- NULL
- }
- })
-
- # observe input$legacymode, if status is switched, click the visualize button
- observeEvent(input$legacymode, {
- # do not run if no file is uploaded
- if (is.null(input$input_excel) == FALSE | input$Samplefile > 0) {
- # if legacymode is switched on, click the visualize button
- click("visualize")
- }
- })
-
- # observe input$scale, if changed, click the visualize button
- observeEvent(input$scale, {
- # do not run if no file is uploaded
- if (is.null(input$input_excel) == FALSE | input$Samplefile > 0) {
- # if scale is changed, click the visualize button
- click("visualize")
- }
- })
-
-
-
-
- observeEvent(input$visualize, {
- withProgress(message = "Making plots", value = 0, {
- workbook <- wb()
- # check that there is a workbook
- if (is.null(workbook)) {
- return()
- }
- disable(id = "visualize")
-
- # if metadata is not None, read metadata
- if (input$sheet3 != "None") {
- metadata <- as.data.frame(readWorkbook(workbook, sheet = input$sheet3, colNames = TRUE, rowNames = TRUE))
- } else {
- metadata <- NULL
- }
-
- # save metadata to session
- session$userData$metadata <- metadata
-
- first_correlation_matrix <-
- as.matrix(readWorkbook(workbook,
- colNames = TRUE,
- rowNames = TRUE,
- input$sheet1
- ))
- rownames(first_correlation_matrix) <- trimws(rownames(first_correlation_matrix))
- colnames(first_correlation_matrix) <- trimws(colnames(first_correlation_matrix))
-
- # adjust format with correct rownames and make matrix
- first_correlation_plot <- corrplot.mixed(
- first_correlation_matrix,
- lower = "color",
- upper = "number",
- is.corr = FALSE,
- order = "hclust",
- diag = "n",
- tl.col = "black",
- number.cex = input$ncex,
- tl.cex = input$tlcex,
- tl.pos = "lt",
- )
- # save to session
- session$userData$first_correlation_matrix <- first_correlation_matrix
- # send first_correlation_matrix as custom message to the client together with the metadata
- # this is used to make the plotly plot
- sendCustomMessage("first_correlation_matrix", list(first_correlation_matrix, metadata))
- # if not legacy, use plotly
- if (input$legacymode == FALSE) {
- output$ANI <- renderPlotly({
- ANI_plot <- single_plot(first_correlation_matrix, metadata)
- session$userData$ANI_order_x <- ANI_plot$x$layout$xaxis$ticktext
- session$userData$ANI_order_y <- ANI_plot$x$layout$yaxis$ticktext
- # also save the plotly plot to a png file
- #single_plot(first_correlation_matrix, metadata, paste0(session$userData$tmp_dir, "/ANI.png"))
- #session$userData$ANI_plot <- ANI_plot
- ANI_plot
- # export the plotly plot to a png file
- })
- } else {
- # update width and height of the plot based on input$scale (default 1000*1000 with scale=1)
- scale=input$scale
- shinyjs::runjs(paste0("$('#ANI_legacy').width(", scale, " * 1000)"))
- shinyjs::runjs(paste0("$('#ANI_legacy').height(", scale, " * 1000)"))
- output$ANI_legacy <- renderPlot(single_plot_legacy(first_correlation_matrix))
- }
-
- # enable the "Matrix 1" tab
- shinyjs::js$enableTab("Matrix\ 1")
- # click on the "Matrix 1" tab if we are on Instructions tab
- shinyjs::js$goToMatrix1tab()
- incProgress(1 / 4)
-
- if (input$sheet2 != "None") {
- ws <- readWorkbook(
- workbook,
- colNames = TRUE,
- rowNames = TRUE,
- sheet = input$sheet2
- )
- rownames(ws) <- trimws(rownames(ws))
- colnames(ws) <- trimws(colnames(ws))
- # adjust format with correct rownames and make matrix
- second_correlation_matrix <- as.matrix(ws)
- second_correlation_plot <- corrplot.mixed(
- second_correlation_matrix,
- lower = "color",
- upper = "number",
- is.corr = FALSE,
- order = "hclust",
- diag = "n",
- tl.col = "black",
- number.cex = input$ncex,
- tl.cex = input$tlcex,
- tl.pos = "lt"
- )
-
- # save to session
- session$userData$second_correlation_matrix <- second_correlation_matrix
-
- # if not legacy, use plotly
- if (input$legacymode == FALSE) {
- output$MALDI <- renderPlotly({
- MALDI_plot <- single_plot(second_correlation_matrix, metadata)
- session$userData$MALDI_order_x <- MALDI_plot$x$layout$xaxis$ticktext
- session$userData$MALDI_order_y <- MALDI_plot$x$layout$yaxis$ticktext
- # also save the plotly plot to a png file
- #single_plot(second_correlation_matrix, metadata, paste0(session$userData$tmp_dir, "/MALDI.png"))
- #session$userData$MALDI_plot <- MALDI_plot
- MALDI_plot})
- } else {
- # update width and height of the plot based on input$scale (default 1000*1000 with scale=1)
- scale=input$scale
- shinyjs::runjs(paste0("$('#MALDI_legacy').width(", scale, " * 1000)"))
- shinyjs::runjs(paste0("$('#MALDI_legacy').height(", scale, " * 1000)"))
- output$MALDI_legacy <- renderPlot(single_plot_legacy(second_correlation_matrix))
- }
- # enable the "Matrix 2" tab
- shinyjs::js$enableTab("Matrix\ 2")
- }
- incProgress(1 / 4)
-
- ## mixed plots
- if (input$sheet1 != "None" && input$sheet2 != "None") {
- # New MALDI matrix with order of columns from ANI matrix
-
- col_matrix1 <- colnames(first_correlation_plot$corr)
- #print(col_matrix1)
- # check if all columns are present in MALDI matrix
- if (check_errors(col_matrix1, colnames(second_correlation_matrix), "ANIMALDI_Error")) {
- ani_maldi <- second_correlation_matrix[col_matrix1, col_matrix1]
- first_correlation_matrix_ordered <- first_correlation_matrix[col_matrix1,col_matrix1]
- #print(ani_maldi)
- # save to session
- session$userData$ani_maldi <- ani_maldi
- if (input$legacymode == FALSE) {
- output$ANIMALDI <- renderPlotly(combined_plot(first_correlation_matrix_ordered, ani_maldi, session$userData$ANI_order_x, session$userData$ANI_order_y, metadata))
- } else {
- # update width and height of the plot based on input$scale (default 1000*1000 with scale=1)
- scale=input$scale
- shinyjs::runjs(paste0("$('#ANIMALDI_legacy').width(", scale, " * 1000)"))
- shinyjs::runjs(paste0("$('#ANIMALDI_legacy').height(", scale, " * 1000)"))
- output$ANIMALDI_legacy <- renderPlot(combined_plot_legacy(first_correlation_matrix_ordered, ani_maldi))
- }
- }
- # enable the "Matrix 1 vs Matrix 2 (ordered on matrix 1)" tab
- shinyjs::js$enableTab("Matrix\ 1\ vs\ Matrix\ 2\ (ordered\ on\ matrix\ 1)")
- incProgress(1 / 4)
-
- # check if all columns are present in ANI matrix
- col_matrix2 <- colnames(second_correlation_plot$corr)
- #print(col_matrix2)
- if (check_errors(col_matrix2, colnames(first_correlation_matrix), "MALDIANI_Error")) {
- maldi_ani <- first_correlation_matrix[col_matrix2, col_matrix2]
- second_correlation_matrix_ordered <- second_correlation_matrix[col_matrix2,col_matrix2]
- #print(maldi_ani)
- # save to session
- session$userData$maldi_ani <- maldi_ani
- if (input$legacymode == FALSE) {
- # if session$userData$MALDI_order_x is NULL, we first need to render the MALDI plot, we don't need to do this if we already have the order :)
- if (is.null(session$userData$MALDI_order_x)) {
- MALDI_plot <- single_plot(second_correlation_matrix, metadata)
- session$userData$MALDI_order_x <- MALDI_plot$x$layout$xaxis$ticktext
- session$userData$MALDI_order_y <- MALDI_plot$x$layout$yaxis$ticktext
- }
- output$MALDIANI <- renderPlotly(combined_plot(maldi_ani, second_correlation_matrix_ordered, session$userData$MALDI_order_x, session$userData$MALDI_order_y, metadata))
- } else {
- # update width and height of the plot based on input$scale (default 1000*1000 with scale=1)
- scale=input$scale
- shinyjs::runjs(paste0("$('#MALDIANI_legacy').width(", scale, " * 1000)"))
- shinyjs::runjs(paste0("$('#MALDIANI_legacy').height(", scale, " * 1000)"))
- output$MALDIANI_legacy <- renderPlot(combined_plot_legacy(maldi_ani, second_correlation_matrix_ordered))
- }
- }
- # enable the "Matrix 2 vs Matrix 1 (ordered on matrix 2)" tab
- shinyjs::js$enableTab("Matrix\ 1\ vs\ Matrix\ 2\ (ordered\ on\ matrix\ 2)")
- incProgress(1 / 4)
- }
-
- enable(id = "visualize")
- })
- })
- # download button # generate matrix1 (ANI) plot as html, add date and time to filename
- output$download1 <- downloadHandler(
- filename = function() {
- paste0(input$yas,"-",Sys.Date(),".html")
- },
- content = function(file) {
- tmpdir <- tempdir()
- #print(tmpdir)
- single_plot(session$userData$first_correlation_matrix, session$userData$metadata, paste0(tmpdir, "/ANI.html"))
- file.copy(paste0(tmpdir, "/ANI.html"), file)
- }
- )
- # repeat for matrix2 (MALDI)
- output$download2 <- downloadHandler(
- filename = function() {
- paste0(input$xas,"-",Sys.Date(),".html")
- },
- content = function(file) {
- tmpdir <- tempdir()
- single_plot(session$userData$second_correlation_matrix, session$userData$metadata, paste0(tmpdir, "/MALDI.html"))
- file.copy(paste0(tmpdir, "/MALDI.html"), file)
- }
- )
- # repeat for matrix1 vs matrix2 (ordered on matrix 1)
- output$download3 <- downloadHandler(
- filename = function() {
- paste0(input$yas,"_vs_",input$xas,"-",Sys.Date(),".html")
- },
- content = function(file) {
- tmpdir <- tempdir()
- combined_plot(session$userData$first_correlation_matrix, session$userData$ani_maldi, session$userData$ANI_order_x, session$userData$ANI_order_y, session$userData$metadata, paste0(tmpdir, "/ANIMALDI.html"))
- file.copy(paste0(tmpdir, "/ANIMALDI.html"), file)
- }
- )
- # repeat for matrix2 vs matrix1 (ordered on matrix 2)
- output$download4 <- downloadHandler(
- filename = function() {
- paste0(input$xas,"_vs_",input$yas,"-",Sys.Date(),".html")
- },
- content = function(file) {
- tmpdir <- tempdir()
- combined_plot(session$userData$maldi_ani, session$userData$second_correlation_matrix, session$userData$MALDI_order_x, session$userData$MALDI_order_y, session$userData$metadata, paste0(tmpdir, "/MALDIANI.html"))
- file.copy(paste0(tmpdir, "/MALDIANI.html"), file)
- }
- )
-
- # we have action buttons to check first if session$userData contains the data we need, if so we click button using shinyjs, otherwise return an alert message
- # ids of buttons are action_download1, action_download2, action_download3, action_download4
- observeEvent(input$action_download1, {
- if (!is.null(session$userData$first_correlation_matrix)) {
- shinyjs::runjs("$('#download1')[0].click()")
- } else {
- # alert message
- createAlert(session=session, anchorId = "alert",content = "Please upload a matrix first or select a sheet for matrix 1",style = "danger")
- }
- })
- observeEvent(input$action_download2, {
- if (!is.null(session$userData$second_correlation_matrix)) {
- shinyjs::runjs("$('#download2')[0].click()")
- } else {
- createAlert(session=session, anchorId = "alert",content = "Please upload a matrix first or select a sheet for matrix 2",style = "danger")
- }
- })
- observeEvent(input$action_download3, {
- if (!is.null(session$userData$first_correlation_matrix) && !is.null(session$userData$ani_maldi)) {
- shinyjs::runjs("$('#download3')[0].click()")
- } else {
- createAlert(session=session, anchorId = "alert",content = "Please upload a matrix first or select a sheet for matrix 1 and 2",style = "danger")
- }
- })
- observeEvent(input$action_download4, {
- if (!is.null(session$userData$second_correlation_matrix) && !is.null(session$userData$maldi_ani)) {
- shinyjs::runjs("$('#download4')[0].click()")
- } else {
- createAlert(session=session, anchorId = "alert",content = "Please upload a matrix first or select a sheet for matrix 1 and 2",style = "danger")
- }
- })
-
-}
+# Session-local state: uploaded matrices are never placed in global caches.
+server <- function(input, output, session) {
+ is_excel <- function(file) {
+ !is.null(file) && tolower(tools::file_ext(file$name)) %in% c("xlsx", "xlsm")
+ }
+ output$first_sheet_ui <- shiny::renderUI({
+ if (!is_excel(input$first_file)) return(NULL)
+ sheets <- tryCatch(matrix_sheet_names(input$first_file$datapath),
+ error = function(e) character())
+ shiny::selectInput("first_sheet", "First matrix worksheet",
+ choices = sheets, selected = sheets[1L])
+ })
+ output$second_sheet_ui <- shiny::renderUI({
+ f <- if (!is.null(input$second_file)) input$second_file else input$first_file
+ if (!is_excel(f)) return(NULL)
+ sheets <- tryCatch(matrix_sheet_names(f$datapath),
+ error = function(e) character())
+ suggested <- if (is.null(input$second_file) && length(sheets) > 1L)
+ sheets[2L] else "None"
+ shiny::selectInput("second_sheet", "Second matrix worksheet",
+ choices = c("None", sheets), selected = suggested)
+ })
+ output$metadata_sheet_ui <- shiny::renderUI({
+ f <- if (!is.null(input$metadata_file)) input$metadata_file else input$first_file
+ if (!is_excel(f)) return(NULL)
+ sheets <- tryCatch(matrix_sheet_names(f$datapath),
+ error = function(e) character())
+ suggested <- if (is.null(input$metadata_file) &&
+ is.null(input$second_file) && length(sheets) > 2L)
+ sheets[3L] else "None"
+ shiny::selectInput("metadata_sheet", "Metadata worksheet",
+ choices = c("None", sheets), selected = suggested)
+ })
+
+ loaded <- shiny::eventReactive(input$visualize, {
+ shiny::req(input$first_file)
+ shiny::withProgress(message = "Validating matrices and computing sample order",
+ value = 0, {
+ tryCatch({
+ f <- input$first_file
+ wb1 <- if (is_excel(f)) openxlsx::loadWorkbook(f$datapath) else NULL
+ a <- read_matrix_input(f$datapath,
+ sheet = if (is_excel(f)) input$first_sheet else NULL,
+ workbook = wb1)
+ a <- validate_matrix(a, input$kind1)
+ shiny::incProgress(0.20)
+ f2 <- if (!is.null(input$second_file)) input$second_file else f
+ has_second <- !is.null(input$second_file) ||
+ (is_excel(f) && !is.null(input$second_sheet) &&
+ input$second_sheet != "None")
+ b <- NULL
+ if (has_second) {
+ if (is_excel(f2) && (is.null(input$second_sheet) ||
+ input$second_sheet == "None"))
+ stop("Select a second matrix worksheet.")
+ wb2 <- if (!is.null(input$second_file) && is_excel(f2))
+ openxlsx::loadWorkbook(f2$datapath) else wb1
+ b <- read_matrix_input(f2$datapath,
+ sheet = if (is_excel(f2)) input$second_sheet else NULL,
+ workbook = wb2)
+ b <- validate_matrix(b, input$kind2)
+ # Match once for early actionable errors; avoid retaining copies.
+ shared_ids <- intersect(colnames(a), colnames(b))
+ if (input$match_mode == "strict" &&
+ !setequal(colnames(a), colnames(b)))
+ stop(sprintf("Matrices have %d and %d isolates; %d are shared. Select 'Shared isolates only' or fix the IDs.",
+ nrow(a), nrow(b), length(shared_ids)))
+ if (length(shared_ids) < 2L) stop("At least two shared isolates are required.")
+ }
+ shiny::incProgress(0.20)
+ meta <- NULL
+ fmeta <- if (!is.null(input$metadata_file))
+ input$metadata_file else f
+ if (is_excel(fmeta) && !is.null(input$metadata_sheet) &&
+ input$metadata_sheet != "None") {
+ wbm <- if (!is.null(input$metadata_file))
+ openxlsx::loadWorkbook(fmeta$datapath) else wb1
+ meta <- read_metadata_input(fmeta$datapath,
+ input$metadata_sheet, wbm)
+ } else if (!is.null(input$metadata_file) && !is_excel(fmeta)) {
+ meta <- read_metadata_input(fmeta$datapath)
+ }
+ if (!is.null(meta) && !all(colnames(a) %in% rownames(meta)))
+ shiny::showNotification("Some matrix samples have no metadata.",
+ type = "warning", duration = 8)
+ explicit <- NULL
+ if (!is.null(input$order_file)) {
+ explicit <- trimws(readLines(input$order_file$datapath, warn = FALSE))
+ explicit <- sub("[,\t].*$", "", explicit)
+ explicit <- explicit[nzchar(explicit) & explicit != "sample_id"]
+ if (anyDuplicated(explicit)) stop("The imported sample order has duplicate IDs.")
+ if (!any(explicit %in% colnames(a)))
+ stop("The imported sample order matches no samples.")
+ }
+ order_for <- function(m, kind) {
+ if (!is.null(explicit))
+ return(c(intersect(explicit, colnames(m)),
+ setdiff(colnames(m), explicit)))
+ matrix_order(m, kind, cluster = isTRUE(input$cluster))$ids
+ }
+ order1 <- order_for(a, input$kind1)
+ shiny::incProgress(0.30)
+ order2 <- if (!is.null(b)) order_for(b, input$kind2) else NULL
+ shiny::incProgress(0.30)
+ shiny::updateSelectInput(session, "metadata_column",
+ choices = c("None" = "", if (!is.null(meta)) names(meta)),
+ selected = "")
+ shiny::showNotification(
+ sprintf("Loaded %d isolates%s.", nrow(a),
+ if (!is.null(b)) paste0(" and ", nrow(b), " in matrix 2")
+ else ""),
+ type = "message", duration = 4)
+ list(first = a, second = b, metadata = meta,
+ order1 = order1, order2 = order2,
+ matching = input$match_mode,
+ clustering_skipped = isTRUE(input$cluster) &&
+ is.null(explicit) &&
+ (nrow(a) > 2000L || (!is.null(b) && nrow(b) > 2000L)),
+ kind1 = input$kind1, kind2 = input$kind2)
+ }, error = function(e) {
+ shiny::showNotification(conditionMessage(e), type = "error",
+ duration = NULL)
+ NULL
+ })
+ })
+ }, ignoreInit = TRUE)
+
+ data <- shiny::reactive({ shiny::req(loaded()); loaded() })
+ has_two <- shiny::reactive(!is.null(data()$second))
+ ordered_first <- shiny::reactive({
+ d <- data()
+ d$first[d$order1, d$order1, drop = FALSE]
+ })
+ ordered_second <- shiny::reactive({
+ d <- data()
+ shiny::req(d$second)
+ d$second[d$order2, d$order2, drop = FALSE]
+ })
+ shared <- shiny::reactive({
+ d <- data()
+ shiny::req(d$second)
+ match_matrices(d$first, d$second, d$matching)
+ })
+ combined_first <- shiny::reactive({
+ d <- data()
+ both <- shared()
+ ids <- d$order1[d$order1 %in% both$ids]
+ list(first = d$first[ids, ids, drop = FALSE],
+ second = d$second[ids, ids, drop = FALSE])
+ })
+ combined_second <- shiny::reactive({
+ d <- data()
+ both <- shared()
+ ids <- d$order2[d$order2 %in% both$ids]
+ list(first = d$second[ids, ids, drop = FALSE],
+ second = d$first[ids, ids, drop = FALSE])
+ })
+ diff_matrix <- shiny::reactive({
+ shiny::req(input$comparable_scales)
+ x <- combined_first()
+ x$first - x$second
+ })
+ pair_data <- shiny::reactive({
+ m <- shared()
+ paired_values(m$first, m$second, max_pairs = input$max_pairs)
+ })
+
+ output$overview <- shiny::renderUI({
+ d <- data()
+ shiny::tagList(
+ shiny::h3("Dataset overview"),
+ shiny::div(class = "metric", sprintf("Matrix 1: %s isolates",
+ format(nrow(d$first), big.mark = ","))),
+ if (!is.null(d$second))
+ shiny::div(class = "metric", sprintf("Matrix 2: %s isolates; %s shared",
+ format(nrow(d$second), big.mark = ","),
+ format(length(shared()$ids), big.mark = ","))),
+ if (!is.null(d$second) && d$matching == "intersection")
+ shiny::p(sprintf("Excluded from comparison: %d only in matrix 1, %d only in matrix 2.",
+ length(shared()$only_first), length(shared()$only_second))),
+ if (d$clustering_skipped)
+ shiny::p("Clustering was skipped above 2,000 isolates. Import a sample order for large datasets."),
+ if (!is.null(d$second) && pair_data()$sampled)
+ shiny::p(sprintf("Pairwise statistics are based on %s of %s distinct possible pairs, sampled with seed %s.",
+ format(pair_data()$evaluated_pairs, big.mark = ","),
+ format(pair_data()$total_pairs, big.mark = ","),
+ pair_data()$seed)),
+ if (!is.null(d$second) && !input$comparable_scales)
+ shiny::p("The matrices may use different units. Pearson and Spearman correlations are reported; raw differences and error metrics are hidden.")
+ )
+ })
+ output$agreement <- shiny::renderTable({
+ shiny::req(has_two())
+ pp <- pair_data()
+ stats <- matrix_agreement(pp)
+ data.frame(
+ Measure = c("Valid isolate pairs", "Pearson r", "Spearman rho",
+ if (input$comparable_scales) c("Mean absolute difference",
+ "Root mean squared difference")),
+ Value = c(stats$n, signif(stats$pearson, 5),
+ signif(stats$spearman, 5),
+ if (input$comparable_scales)
+ c(signif(stats$mae, 5), signif(stats$rmse, 5)))
+ )
+ })
+ output$replicate_table <- shiny::renderTable({
+ d <- data()
+ shiny::req(d$metadata, nzchar(input$metadata_column))
+ replicate_summary(d$first, d$metadata, input$metadata_column,
+ max_pairs = input$max_pairs)
+ })
+
+ # Each plot is calculated only when its tab is shown. Palette changes do
+ # not reload workbooks or recompute clustering.
+ make_view <- function(prefix, source, name, combined = FALSE,
+ first_label = "Matrix 1", second_label = "Matrix 2",
+ is_difference = FALSE) {
+ output[[paste0(prefix, "_ui")]] <- shiny::renderUI({
+ if (is_difference) shiny::req(input$comparable_scales)
+ m <- source()
+ size <- if (combined) nrow(m$first) else nrow(m)
+ if (size <= 300L)
+ plotly::plotlyOutput(paste0(prefix, "_interactive"),
+ width = "100%", height = "750px")
+ else shiny::tagList(
+ shiny::p("Large-matrix raster preview. Click a cell to inspect its exact original value."),
+ shiny::plotOutput(paste0(prefix, "_raster"),
+ width = "100%", height = "850px",
+ click = paste0(prefix, "_click"))
+ )
+ })
+ output[[paste0(prefix, "_interactive")]] <- plotly::renderPlotly({
+ if (is_difference) shiny::req(input$comparable_scales)
+ m <- source()
+ size <- if (combined) nrow(m$first) else nrow(m)
+ shiny::req(size <= 300L)
+ ma_interactive(m, name = name, palette = input$palette,
+ show_numbers = input$show_numbers, combined = combined,
+ first_name = first_label, second_name = second_label,
+ log_scale = input$log_scale && !is_difference)
+ })
+ output[[paste0(prefix, "_raster")]] <- shiny::renderPlot({
+ if (is_difference) shiny::req(input$comparable_scales)
+ m <- source()
+ size <- if (combined) nrow(m$first) else nrow(m)
+ shiny::req(size > 300L)
+ if (combined) {
+ m <- ma_combined(m$first, m$second, input$log_scale)$values
+ normal <- TRUE
+ } else normal <- FALSE
+ graphics::par(mar = c(9, 9, 5, 2))
+ d <- data()
+ meta <- if (is_difference || !nzchar(input$metadata_column)) NULL
+ else d$metadata
+ ma_raster(m, palette = input$palette, title = name,
+ metadata = meta,
+ group_column = input$metadata_column, normalized = normal,
+ log_scale = input$log_scale && !is_difference)
+ }, res = 110)
+ output[[paste0(prefix, "_cell")]] <- shiny::renderText({
+ shiny::req(input[[paste0(prefix, "_click")]])
+ if (is_difference) shiny::req(input$comparable_scales)
+ m <- source()
+ original <- if (combined)
+ ma_combined(m$first, m$second, input$log_scale)$original else m
+ preview <- ma_preview(original)
+ click <- input[[paste0(prefix, "_click")]]
+ col <- as.integer(round(click$x))
+ row <- as.integer(nrow(preview$matrix) + 1 - round(click$y))
+ if (is.na(col) || is.na(row) || col < 1L ||
+ row < 1L || col > nrow(preview$matrix) ||
+ row > nrow(preview$matrix)) return("")
+ i <- preview$indices[row]
+ j <- preview$indices[col]
+ sprintf("%s / %s: %s%s", rownames(original)[i],
+ colnames(original)[j], format(original[i, j], digits = 9),
+ if (combined) if (i < j) paste0(" (", first_label, ")")
+ else if (i > j) paste0(" (", second_label, ")")
+ else " (diagonal)" else "")
+ })
+ }
+ make_view("plot1", ordered_first, "Matrix 1")
+ make_view("plot2", ordered_second, "Matrix 2")
+ make_view("combined1", combined_first, "Combined, ordered by matrix 1",
+ combined = TRUE)
+ make_view("combined2", combined_second, "Combined, ordered by matrix 2",
+ combined = TRUE, first_label = "Matrix 2", second_label = "Matrix 1")
+ make_view("difference", diff_matrix, "Matrix 1 minus matrix 2",
+ is_difference = TRUE)
+
+ output$scatter <- shiny::renderPlot({
+ shiny::req(has_two())
+ p <- pair_data()
+ d <- p$data
+ shiny::validate(shiny::need(nrow(d) >= 2L,
+ "At least two nonmissing sample pairs are needed."))
+ graphics::plot(d$first, d$second, pch = 16L,
+ col = grDevices::adjustcolor("#305f86", alpha.f = 0.27),
+ cex = 0.5, xlab = "Matrix 1", ylab = "Matrix 2",
+ main = sprintf("Distinct isolate pairs (n = %s%s)",
+ format(nrow(d), big.mark = ","),
+ if (p$sampled) ", sampled" else ""))
+ if (input$comparable_scales) graphics::abline(0, 1, lty = 2)
+ })
+ output$discrepancies <- shiny::renderTable({
+ shiny::req(has_two(), input$comparable_scales)
+ d <- pair_data()$data
+ d <- d[order(abs(d$difference), decreasing = TRUE), , drop = FALSE]
+ utils::head(d, 20L)
+ }, digits = 5)
+
+ concordance <- shiny::reactive({
+ shiny::req(has_two())
+ m <- shared()
+ shiny::validate(shiny::need(nrow(m$first) <= 2000L,
+ "Cluster concordance is limited to 2,000 shared isolates."))
+ shiny::validate(shiny::need(input$cluster_k < nrow(m$first),
+ "Choose fewer clusters than shared isolates."))
+ cluster_concordance(m$first, m$second, kind_a = data()$kind1,
+ kind_b = data()$kind2, k = input$cluster_k)
+ })
+ output$cluster_summary <- shiny::renderTable({
+ c <- concordance()
+ data.frame(measure = "Adjusted Rand index", value = c$ari)
+ })
+ output$cluster_table <- shiny::renderTable({
+ utils::head(concordance()$assignments, 100L)
+ })
+ output$metadata_preview <- shiny::renderTable({
+ shiny::req(data()$metadata)
+ utils::head(data()$metadata, 100L)
+ }, rownames = TRUE)
+ output$metadata_note <- shiny::renderUI({
+ shiny::req(data()$metadata)
+ shiny::p("Showing up to 100 metadata rows. Choose a categorical metadata column in the sidebar to compare within-group and between-group matrix values.")
+ })
+
+ mantel_result <- shiny::eventReactive(input$run_mantel, {
+ shiny::req(has_two())
+ tryCatch({
+ x <- shared()
+ matrix_mantel(x$first, x$second, permutations = input$permutations)
+ }, error = function(e) list(error = conditionMessage(e)))
+ })
+ output$mantel_result <- shiny::renderPrint({
+ shiny::req(mantel_result())
+ r <- mantel_result()
+ if (!is.null(r$error)) cat("Mantel test: ", r$error, "\n")
+ else cat(sprintf("Mantel test: r = %.5f, two-sided permutation p = %.5g; %d label permutations, seed %d.\n",
+ r$observed, r$p_two_sided, r$permutations, r$seed))
+ })
+
+ output$download_first <- shiny::downloadHandler(
+ filename = function() "MAniR_matrix1.csv",
+ content = function(file) write_matrix_csv(data()$first, file))
+ output$download_second <- shiny::downloadHandler(
+ filename = function() "MAniR_matrix2.csv",
+ content = function(file) {
+ shiny::req(data()$second)
+ write_matrix_csv(data()$second, file)
+ })
+ output$download_pairs <- shiny::downloadHandler(
+ filename = function() "MAniR_pairwise_values.csv",
+ content = function(file) {
+ shiny::req(has_two())
+ utils::write.csv(pair_data()$data, file, row.names = FALSE)
+ })
+ output$download_clusters <- shiny::downloadHandler(
+ filename = function() "MAniR_clusters.csv",
+ content = function(file) utils::write.csv(
+ concordance()$assignments, file, row.names = FALSE))
+ output$download_rds <- shiny::downloadHandler(
+ filename = function() "MAniR_matrices.rds",
+ content = function(file) {
+ d <- data()
+ saveRDS(list(first = d$first, second = d$second,
+ metadata = d$metadata, order1 = d$order1,
+ order2 = d$order2, matching = d$matching,
+ kinds = c(d$kind1, d$kind2)), file)
+ })
+ output$download_first_png <- shiny::downloadHandler(
+ filename = function() "MAniR_matrix1.png",
+ content = function(file)
+ ma_save_png(ordered_first(), file, palette = input$palette,
+ title = "Matrix 1"))
+ output$download_combined_png <- shiny::downloadHandler(
+ filename = function() "MAniR_combined.png",
+ content = function(file) {
+ m <- combined_first()
+ ma_save_png(ma_combined(m$first, m$second, input$log_scale)$values,
+ file, palette = input$palette,
+ title = "Combined matrix, independently display-scaled",
+ normalized = TRUE)
+ })
+ output$download_settings <- shiny::downloadHandler(
+ filename = function() "MAniR_analysis_settings.txt",
+ content = function(file) {
+ d <- data()
+ writeLines(c(
+ paste("MAniR generated:", Sys.time()),
+ paste("First matrix:", nrow(d$first), "isolates"),
+ paste("Second matrix:", if (is.null(d$second)) "None" else nrow(d$second)),
+ paste("Matrix kinds:", d$kind1, d$kind2),
+ paste("Sample matching:", d$matching),
+ paste("Cluster requested:", input$cluster),
+ paste("Cluster limit:", 2000L),
+ paste("Color palette:", input$palette),
+ paste("Log display scaling:", input$log_scale),
+ paste("Comparable units:", input$comparable_scales),
+ paste("Maximum analyzed pairs:", input$max_pairs),
+ paste("Pair sampling seed:", 1L),
+ paste("Mantel seed:", 1L),
+ paste("Session:", paste(utils::capture.output(utils::sessionInfo()),
+ collapse = "\n"))
+ ), file)
+ })
+}
From 82977116111ae3c26fe6490cf388ee97e4249f3b Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:22:16 +0200
Subject: [PATCH 008/134] fix: detect upload formats from original filenames
---
R/matrix_io.R | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/R/matrix_io.R b/R/matrix_io.R
index 93fe0ba..6a83afe 100644
--- a/R/matrix_io.R
+++ b/R/matrix_io.R
@@ -1,12 +1,12 @@
# Input and validation shared by the Shiny application and command-line tools.
-matrix_sheet_names <- function(path) {
- ext <- tolower(tools::file_ext(path))
+matrix_sheet_names <- function(path, format = path) {
+ ext <- tolower(tools::file_ext(format))
if (ext %in% c("xlsx", "xlsm")) return(openxlsx::getSheetNames(path))
character()
}
-read_matrix_input <- function(path, sheet = NULL, workbook = NULL) {
+read_matrix_input <- function(path, sheet = NULL, workbook = NULL, format = path) {
ext <- tolower(tools::file_ext(path))
if (ext %in% c("xlsx", "xlsm")) {
if (is.null(sheet) || identical(sheet, "")) stop("Select a matrix worksheet.")
@@ -16,7 +16,7 @@ read_matrix_input <- function(path, sheet = NULL, workbook = NULL) {
} else if (ext == "rds") {
x <- readRDS(path)
} else if (ext %in% c("csv", "tsv", "txt", "gz")) {
- sep <- if (ext == "tsv" || grepl("\\.tsv\\.gz$", path, ignore.case = TRUE)) "\t" else ","
+ sep <- if (ext == "tsv" || grepl("\\.tsv\\.gz$", format, ignore.case = TRUE)) "\t" else ","
if (requireNamespace("data.table", quietly = TRUE)) {
x <- data.table::fread(path, sep = sep, data.table = FALSE,
check.names = FALSE, showProgress = FALSE)
@@ -84,7 +84,7 @@ validate_matrix <- function(x, kind = c("auto", "similarity", "correlation", "di
m
}
-read_metadata_input <- function(path, sheet = NULL, workbook = NULL) {
+read_metadata_input <- function(path, sheet = NULL, workbook = NULL, format = path) {
ext <- tolower(tools::file_ext(path))
if (ext %in% c("xlsx", "xlsm")) {
if (is.null(sheet) || sheet == "None") return(NULL)
@@ -94,7 +94,7 @@ read_metadata_input <- function(path, sheet = NULL, workbook = NULL) {
} else if (ext == "rds") {
x <- readRDS(path)
} else {
- sep <- if (ext == "tsv" || grepl("\\.tsv\\.gz$", path, ignore.case = TRUE)) "\t" else ","
+ sep <- if (ext == "tsv" || grepl("\\.tsv\\.gz$", format, ignore.case = TRUE)) "\t" else ","
x <- utils::read.table(path, sep = sep, header = TRUE, row.names = 1L,
check.names = FALSE, stringsAsFactors = FALSE)
}
From fe3c3293c3003cf83ed25e2185256e8c02e0446a Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:22:18 +0200
Subject: [PATCH 009/134] fix: combined view dimensions and categorical raster
annotations
---
R/matrix_plot.R | 21 +++++++++++++++++++--
1 file changed, 19 insertions(+), 2 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 32d800c..6129501 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -57,7 +57,7 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
log_scale = FALSE) {
if (!requireNamespace("plotly", quietly = TRUE))
stop("Install the plotly package for interactive plots.")
- n <- nrow(m)
+ n <- if (combined) nrow(m$first) else nrow(m)
if (n > 300L) stop("Use the raster renderer for matrices above 300 isolates.")
if (combined) {
norm <- ma_combined(m$first, m$second, log_scale)
@@ -129,7 +129,7 @@ ma_raster <- function(m, palette = "RdBu", title = "",
pix <- grDevices::as.raster(color_matrix)
n <- nrow(x)
show_at <- unique(as.integer(round(seq(1, n, length.out = min(n, 16L)))))
- plot(NA, xlim = c(0.5, n + 0.5), ylim = c(0.5, n + 0.5),
+ plot(NA, xlim = c(0, n + 0.5), ylim = c(0.5, n + 0.5),
xaxs = "i", yaxs = "i", asp = 1, axes = FALSE,
xlab = "", ylab = "", main = title)
graphics::rasterImage(pix, 0.5, 0.5, n + 0.5, n + 0.5,
@@ -139,6 +139,23 @@ ma_raster <- function(m, palette = "RdBu", title = "",
axis(2, at = n + 1L - show_at, labels = rownames(x)[show_at],
las = 2, cex.axis = 0.55)
box()
+ if (!is.null(metadata) && !is.null(group_column) && nzchar(group_column) &&
+ group_column %in% colnames(metadata)) {
+ groups <- as.character(metadata[match(rownames(x), rownames(metadata)), group_column])
+ valid <- !is.na(groups)
+ levels <- unique(groups[valid])
+ annotation_colors <- grDevices::hcl.colors(max(1L, length(levels)), "Set 2")
+ fill <- annotation_colors[match(groups, levels)]
+ if (any(valid)) {
+ # The left-hand color strip represents the selected metadata field.
+ graphics::rect(0.06, n - which(valid) + 0.5, 0.40,
+ n - which(valid) + 1.5, col = fill[valid], border = NA)
+ if (length(levels) <= 12L) {
+ graphics::legend("topright", legend = levels, fill = annotation_colors,
+ cex = 0.55, bty = "n")
+ }
+ }
+ }
if (preview$sampled)
mtext(sprintf("Representative view: %d of %d isolates. Inspect original values in the table.",
n, preview$full_n), side = 3, line = 0, cex = 0.75)
From 024e386b18fb30a9dbb6b9098b1d340150967d88 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:22:31 +0200
Subject: [PATCH 010/134] fix: pass original Shiny upload formats to all
readers
---
server.R | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/server.R b/server.R
index ab72090..c8c4b5b 100644
--- a/server.R
+++ b/server.R
@@ -5,7 +5,7 @@ server <- function(input, output, session) {
}
output$first_sheet_ui <- shiny::renderUI({
if (!is_excel(input$first_file)) return(NULL)
- sheets <- tryCatch(matrix_sheet_names(input$first_file$datapath),
+ sheets <- tryCatch(matrix_sheet_names(input$first_file$datapath, input$first_file$name),
error = function(e) character())
shiny::selectInput("first_sheet", "First matrix worksheet",
choices = sheets, selected = sheets[1L])
@@ -13,7 +13,7 @@ server <- function(input, output, session) {
output$second_sheet_ui <- shiny::renderUI({
f <- if (!is.null(input$second_file)) input$second_file else input$first_file
if (!is_excel(f)) return(NULL)
- sheets <- tryCatch(matrix_sheet_names(f$datapath),
+ sheets <- tryCatch(matrix_sheet_names(f$datapath, f$name),
error = function(e) character())
suggested <- if (is.null(input$second_file) && length(sheets) > 1L)
sheets[2L] else "None"
@@ -23,7 +23,7 @@ server <- function(input, output, session) {
output$metadata_sheet_ui <- shiny::renderUI({
f <- if (!is.null(input$metadata_file)) input$metadata_file else input$first_file
if (!is_excel(f)) return(NULL)
- sheets <- tryCatch(matrix_sheet_names(f$datapath),
+ sheets <- tryCatch(matrix_sheet_names(f$datapath, f$name),
error = function(e) character())
suggested <- if (is.null(input$metadata_file) &&
is.null(input$second_file) && length(sheets) > 2L)
@@ -41,7 +41,7 @@ server <- function(input, output, session) {
wb1 <- if (is_excel(f)) openxlsx::loadWorkbook(f$datapath) else NULL
a <- read_matrix_input(f$datapath,
sheet = if (is_excel(f)) input$first_sheet else NULL,
- workbook = wb1)
+ workbook = wb1, format = f$name)
a <- validate_matrix(a, input$kind1)
shiny::incProgress(0.20)
f2 <- if (!is.null(input$second_file)) input$second_file else f
@@ -57,7 +57,7 @@ server <- function(input, output, session) {
openxlsx::loadWorkbook(f2$datapath) else wb1
b <- read_matrix_input(f2$datapath,
sheet = if (is_excel(f2)) input$second_sheet else NULL,
- workbook = wb2)
+ workbook = wb2, format = f2$name)
b <- validate_matrix(b, input$kind2)
# Match once for early actionable errors; avoid retaining copies.
shared_ids <- intersect(colnames(a), colnames(b))
@@ -76,9 +76,9 @@ server <- function(input, output, session) {
wbm <- if (!is.null(input$metadata_file))
openxlsx::loadWorkbook(fmeta$datapath) else wb1
meta <- read_metadata_input(fmeta$datapath,
- input$metadata_sheet, wbm)
+ input$metadata_sheet, wbm, format = fmeta$name)
} else if (!is.null(input$metadata_file) && !is_excel(fmeta)) {
- meta <- read_metadata_input(fmeta$datapath)
+ meta <- read_metadata_input(fmeta$datapath, format = fmeta$name)
}
if (!is.null(meta) && !all(colnames(a) %in% rownames(meta)))
shiny::showNotification("Some matrix samples have no metadata.",
From 356225dd459f52738e92da49a51a75d91283c93d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:22:38 +0200
Subject: [PATCH 011/134] fix: use original filename to detect XLSX, CSV, TSV
and RDS
---
R/matrix_io.R | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/R/matrix_io.R b/R/matrix_io.R
index 6a83afe..c56b016 100644
--- a/R/matrix_io.R
+++ b/R/matrix_io.R
@@ -7,7 +7,7 @@ matrix_sheet_names <- function(path, format = path) {
}
read_matrix_input <- function(path, sheet = NULL, workbook = NULL, format = path) {
- ext <- tolower(tools::file_ext(path))
+ ext <- tolower(tools::file_ext(format))
if (ext %in% c("xlsx", "xlsm")) {
if (is.null(sheet) || identical(sheet, "")) stop("Select a matrix worksheet.")
if (is.null(workbook)) workbook <- openxlsx::loadWorkbook(path)
@@ -85,7 +85,7 @@ validate_matrix <- function(x, kind = c("auto", "similarity", "correlation", "di
}
read_metadata_input <- function(path, sheet = NULL, workbook = NULL, format = path) {
- ext <- tolower(tools::file_ext(path))
+ ext <- tolower(tools::file_ext(format))
if (ext %in% c("xlsx", "xlsm")) {
if (is.null(sheet) || sheet == "None") return(NULL)
if (is.null(workbook)) workbook <- openxlsx::loadWorkbook(path)
From 61d1972526e72ff7827d10a27680a78721860f57 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:23:00 +0200
Subject: [PATCH 012/134] build: declare MAniR runtime and optional accelerated
dependencies
---
DESCRIPTION | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 DESCRIPTION
diff --git a/DESCRIPTION b/DESCRIPTION
new file mode 100644
index 0000000..02cae0a
--- /dev/null
+++ b/DESCRIPTION
@@ -0,0 +1,20 @@
+Package: MAniR
+Type: Project
+Title: Interactive Pairwise Matrix Comparison for MALDI-TOF and Genomic Analysis
+Version: 3.0.0
+Authors@R: person("BiKC", role = c("aut", "cre"), email = "opensource@example.invalid")
+Description: Shiny application to compare similarity, correlation and distance
+ matrices using scalable visualization, isolate-level metadata and
+ pairwise analytical summaries. The application is run directly from app.R.
+License: GPL-3
+Encoding: UTF-8
+Depends:
+ R (>= 4.2.0)
+Imports:
+ shiny,
+ plotly,
+ openxlsx,
+ RColorBrewer
+Suggests:
+ data.table,
+ fastcluster
From 32c52804d63c6d5750ec259c2250de4156198cc3 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:23:24 +0200
Subject: [PATCH 013/134] test: cover matrix validation, exact values,
clustering, Mantel, sampling and raster output
---
tests/run_tests.R | 172 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 172 insertions(+)
create mode 100644 tests/run_tests.R
diff --git a/tests/run_tests.R b/tests/run_tests.R
new file mode 100644
index 0000000..2f25990
--- /dev/null
+++ b/tests/run_tests.R
@@ -0,0 +1,172 @@
+# Run with: Rscript tests/run_tests.R
+source("R/matrix_io.R")
+source("R/matrix_analysis.R")
+source("R/matrix_plot.R")
+
+expect_error <- function(expr, pattern = NULL) {
+ e <- tryCatch({force(expr); NULL}, error = function(e) conditionMessage(e))
+ if (is.null(e)) stop("Expected an error, but none was raised.", call. = FALSE)
+ if (!is.null(pattern) && !grepl(pattern, e, ignore.case = TRUE))
+ stop("Expected error matching: ", pattern, "; got: ", e, call. = FALSE)
+ invisible(e)
+}
+test <- function(label, expression) {
+ force(expression)
+ cat("PASS:", label, "\n")
+}
+
+ids <- c("Isolate B", "Isolate A", "Isolate C", "Isolate D")
+m <- matrix(c(
+ 100, 80, 40, 30,
+ 80,100, 45, 35,
+ 40, 45,100, 95,
+ 30, 35, 95,100
+), 4, byrow = TRUE, dimnames = list(ids, ids))
+test("matrix import reorders named rows to named columns", {
+ scrambled <- m[c(3, 1, 4, 2), , drop = FALSE]
+ imported <- validate_matrix(scrambled, "similarity")
+ stopifnot(identical(dimnames(imported), dimnames(m)),
+ isTRUE(all.equal(unname(imported), unname(m),
+ check.attributes = FALSE)))
+})
+test("matrix identifiers are trimmed without changing values", {
+ dirty <- m
+ rownames(dirty) <- paste0(" ", rownames(dirty), " ")
+ colnames(dirty) <- paste0(" ", colnames(dirty), " ")
+ cleaned <- validate_matrix(dirty)
+ stopifnot(identical(rownames(cleaned), ids),
+ identical(colnames(cleaned), ids))
+})
+test("duplicate, inconsistent, nonnumeric, negative and asymmetric data fail", {
+ x <- m; rownames(x)[2] <- rownames(x)[1]
+ expect_error(validate_matrix(x), "Duplicate")
+ x <- m; colnames(x)[2] <- "Wrong ID"
+ expect_error(validate_matrix(x), "differ")
+ x <- as.data.frame(m); x[1, 3] <- "not a number"
+ expect_error(validate_matrix(x), "nonnumeric")
+ x <- m; x[1, 2] <- 70
+ expect_error(validate_matrix(x), "symmetric")
+ x <- m; x[1, 2] <- -1; x[2, 1] <- -1
+ expect_error(validate_matrix(x, "similarity"), "negative")
+ x <- m; x[1, 2] <- 2; x[2, 1] <- 2
+ expect_error(validate_matrix(x, "correlation"), "between -1 and 1")
+})
+test("missing values must occur in mirrored pairs", {
+ x <- m; x[1, 2] <- NA_real_
+ expect_error(validate_matrix(x), "mirrored")
+ x[2, 1] <- NA_real_
+ stopifnot(is.na(validate_matrix(x)[1, 2]))
+})
+test("matched matrices preserve exact values", {
+ reversed <- m[rev(ids), rev(ids), drop = FALSE] / 100
+ mm <- match_matrices(m, reversed)
+ stopifnot(identical(mm$ids, ids),
+ isTRUE(all.equal(mm$second, m / 100,
+ check.attributes = FALSE)))
+})
+test("intersection reports excluded samples", {
+ x <- m[1:3, 1:3]
+ y <- m[2:4, 2:4]
+ res <- match_matrices(x, y, "intersection")
+ stopifnot(length(res$ids) == 2L,
+ identical(res$only_first, ids[1]),
+ identical(res$only_second, ids[4]))
+ expect_error(match_matrices(x, y, "strict"), "differ")
+})
+test("pair indices cover the strictly upper triangle exactly once", {
+ p <- upper_pair_indices(4, max_pairs = 100)
+ stopifnot(identical(p$i, c(1L, 1L, 2L, 1L, 2L, 3L)),
+ identical(p$j, c(2L, 3L, 3L, 4L, 4L, 4L)),
+ p$total == 6, !p$sampled)
+})
+test("sampling is reproducible and does not alter the session RNG", {
+ set.seed(1234)
+ before <- .Random.seed
+ p1 <- upper_pair_indices(500, 1000, seed = 41)
+ stopifnot(identical(before, .Random.seed))
+ p2 <- upper_pair_indices(500, 1000, seed = 41)
+ stopifnot(identical(p1, p2), p1$sampled,
+ all(p1$i < p1$j), !anyDuplicated(paste(p1$i, p1$j)))
+})
+test("matrix pair comparison excludes mirrored and diagonal pairs", {
+ pp <- paired_values(m, m * 2)
+ stopifnot(nrow(pp$data) == 6L, !pp$sampled)
+ stats <- matrix_agreement(pp)
+ stopifnot(abs(stats$pearson - 1) < 1e-12,
+ abs(stats$spearman - 1) < 1e-12)
+})
+test("scale handling preserves source matrices", {
+ before <- m
+ combined <- ma_combined(m, m / 100)
+ stopifnot(identical(m, before),
+ identical(combined$original[1, 2], m[1, 2]),
+ identical(combined$original[2, 1], m[2, 1] / 100),
+ all(combined$values >= 0 & combined$values <= 1))
+ constant <- matrix(4, 2, 2)
+ stopifnot(all(ma_scale(constant) == 0.5))
+ expect_error(ma_scale(matrix(c(0, 1, 2, 3), 2), TRUE), "positive")
+})
+test("cluster ordering and adjusted Rand index are reproducible", {
+ c1 <- matrix_order(m)
+ c2 <- matrix_order(m)
+ stopifnot(identical(c1$ids, c2$ids),
+ identical(sort(c1$ids), sort(ids)),
+ adjusted_rand(c(1, 1, 2, 2), c(2, 2, 1, 1)) == 1)
+ result <- cluster_concordance(m, m, k = 2)
+ stopifnot(abs(result$ari - 1) < 1e-12,
+ nrow(result$assignments) == 4L)
+ skip <- matrix_order(m, max_cluster = 2L)
+ stopifnot(isTRUE(skip$skipped), is.null(skip$tree))
+})
+test("Mantel permutation uses label permutations and restores RNG", {
+ set.seed(48)
+ before <- .Random.seed
+ res <- matrix_mantel(m, m, permutations = 99, seed = 44)
+ stopifnot(abs(res$observed - 1) < 1e-12,
+ res$p_two_sided > 0 && res$p_two_sided <= 1,
+ identical(before, .Random.seed))
+})
+test("metadata alignment and replicate summaries work", {
+ meta <- data.frame(group = c("G1", "G1", "G2", "G2"),
+ row.names = rev(ids))
+ aligned <- align_metadata(meta, ids)
+ stopifnot(identical(rownames(aligned), ids))
+ summary <- replicate_summary(m, meta, "group")
+ stopifnot(nrow(summary) == 2L, sum(summary$pairs) == 6L)
+})
+test("CSV export and reimport retain original values", {
+ path <- tempfile(fileext = ".csv")
+ write_matrix_csv(m, path)
+ imported <- read_matrix_input(path)
+ stopifnot(isTRUE(all.equal(m, imported, check.attributes = FALSE)))
+})
+test("compressed TSV input keeps names and numeric precision", {
+ path <- tempfile(fileext = ".tsv.gz")
+ con <- gzfile(path, "wt")
+ utils::write.table(data.frame(sample_id = ids, m, check.names = FALSE),
+ file = con, sep = "\t", row.names = FALSE, quote = TRUE)
+ close(con)
+ imported <- read_matrix_input(path)
+ stopifnot(isTRUE(all.equal(m, imported, check.attributes = FALSE)))
+})
+test("RDS input supports numeric matrices", {
+ path <- tempfile(fileext = ".rds")
+ saveRDS(m, path)
+ imported <- read_matrix_input(path)
+ stopifnot(isTRUE(all.equal(m, imported, check.attributes = FALSE)))
+})
+test("large matrix preview is bounded and retains exact sampled values", {
+ big <- matrix(1, 900, 900)
+ diag(big) <- 100
+ dimnames(big) <- list(paste0("I", seq_len(900)),
+ paste0("I", seq_len(900)))
+ preview <- ma_preview(big, max_side = 128)
+ stopifnot(identical(dim(preview$matrix), c(128L, 128L)),
+ identical(preview$matrix,
+ big[preview$indices, preview$indices, drop = FALSE]))
+ png_file <- tempfile(fileext = ".png")
+ ma_save_png(big, png_file, max_side = 128)
+ stopifnot(file.exists(png_file), file.info(png_file)$size > 0)
+})
+
+cat("All MAniR regression tests passed.\n")
From 008105197ee274e9d44d470faf254010e02269be Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:23:48 +0200
Subject: [PATCH 014/134] bench: add repeatable staged scalability and memory
benchmark harness
---
benchmarks/benchmark.R | 74 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 74 insertions(+)
create mode 100644 benchmarks/benchmark.R
diff --git a/benchmarks/benchmark.R b/benchmarks/benchmark.R
new file mode 100644
index 0000000..c550c03
--- /dev/null
+++ b/benchmarks/benchmark.R
@@ -0,0 +1,74 @@
+#!/usr/bin/env Rscript
+# Benchmark selected calculation and display stages. Run with:
+# Rscript benchmarks/benchmark.R
+# Rscript benchmarks/benchmark.R --smoke
+# Rscript benchmarks/benchmark.R --large
+# Results are hardware-dependent; repeated runs and environment details
+# are required before reporting performance claims.
+
+source("R/matrix_io.R")
+source("R/matrix_analysis.R")
+source("R/matrix_plot.R")
+
+args <- commandArgs(trailingOnly = TRUE)
+smoke <- "--smoke" %in% args
+large <- "--large" %in% args
+sizes <- if (smoke) c(60L, 120L) else if (large)
+ c(500L, 1000L, 2500L, 5000L, 10000L)
+else c(100L, 300L, 600L, 1000L, 2500L)
+repeats <- if (smoke) 1L else 3L
+dir.create("benchmarks/results", recursive = TRUE, showWarnings = FALSE)
+results <- list()
+idx <- 1L
+
+for (n in sizes) {
+ cat("Generating synthetic matrix for", n, "isolates\n")
+ set.seed(777)
+ features <- matrix(stats::rnorm(n * 7L), nrow = n, ncol = 7L)
+ m <- exp(-as.matrix(stats::dist(features)) / 3)
+ ids <- sprintf("isolate_%06d", seq_len(n))
+ dimnames(m) <- list(ids, ids)
+ diag(m) <- 1
+ rm(features)
+ gc()
+ for (repeat_index in seq_len(repeats)) {
+ run_case <- function(stage, expr) {
+ gc(reset = TRUE)
+ start <- proc.time()[["elapsed"]]
+ result <- force(expr)
+ elapsed <- proc.time()[["elapsed"]] - start
+ # R's reported high-water vector heap is approximate, not process RSS.
+ approx_heap_mb <- unname(gc()[2L, "max used"]) * 8 / 1024^2
+ results[[idx]] <<- data.frame(
+ n = n, repeat_index = repeat_index, stage = stage,
+ seconds = elapsed, approx_vector_heap_mb = approx_heap_mb,
+ renderer = "MAniR v3", stringsAsFactors = FALSE)
+ idx <<- idx + 1L
+ invisible(result)
+ }
+ run_case("validate", validate_matrix(m, kind = "similarity"))
+ if (n <= 2500L) {
+ run_case("cluster", matrix_order(m, kind = "similarity"))
+ } else {
+ run_case("skip_cluster_large", matrix_order(m, kind = "similarity"))
+ }
+ run_case("pair_sampling_100k", paired_values(m, m,
+ max_pairs = 100000L))
+ run_case("raster_512", {
+ file <- tempfile(fileext = ".png")
+ ma_save_png(m, file, max_side = 512L)
+ unlink(file)
+ })
+ }
+ rm(m)
+ gc()
+}
+result <- do.call(rbind, results)
+print(result, row.names = FALSE)
+out <- file.path("benchmarks/results",
+ paste0("MAniR-", format(Sys.time(), "%Y%m%d-%H%M%S"),
+ if (smoke) "-smoke" else "", ".csv"))
+utils::write.csv(result, out, row.names = FALSE)
+cat("Results written to:", out, "\n")
+cat("R version:", R.version.string, "\n")
+print(utils::sessionInfo())
From a82c2f7010fb2ebe1b832660f17b4411b5ebab20 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:23:55 +0200
Subject: [PATCH 015/134] fix: read compressed text matrices without optional
R.utils
---
R/matrix_io.R | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/R/matrix_io.R b/R/matrix_io.R
index c56b016..6aa53f7 100644
--- a/R/matrix_io.R
+++ b/R/matrix_io.R
@@ -17,7 +17,7 @@ read_matrix_input <- function(path, sheet = NULL, workbook = NULL, format = path
x <- readRDS(path)
} else if (ext %in% c("csv", "tsv", "txt", "gz")) {
sep <- if (ext == "tsv" || grepl("\\.tsv\\.gz$", format, ignore.case = TRUE)) "\t" else ","
- if (requireNamespace("data.table", quietly = TRUE)) {
+ if (ext != "gz" && requireNamespace("data.table", quietly = TRUE)) {
x <- data.table::fread(path, sep = sep, data.table = FALSE,
check.names = FALSE, showProgress = FALSE)
if (ncol(x) < 2L) stop("The file needs an ID column and matrix columns.")
From f2e4736bba570206c891004ff753fdf04bf5befa Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:23:58 +0200
Subject: [PATCH 016/134] fix: close PNG device safely after plotting
---
R/matrix_plot.R | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 6129501..33ad946 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -166,8 +166,7 @@ ma_save_png <- function(m, file, palette = "RdBu", title = "",
max_side = 1200L, normalized = FALSE) {
grDevices::png(file, width = 1700L, height = 1600L, res = 160L)
on.exit(grDevices::dev.off(), add = TRUE)
- op <- graphics::par(mar = c(9, 9, 5, 2))
- on.exit(graphics::par(op), add = TRUE)
+ graphics::par(mar = c(9, 9, 5, 2))
ma_raster(m, palette = palette, title = title, max_side = max_side,
normalized = normalized)
invisible(file)
From 9af50c060b53b826c1bbf88c35dbc5294b0fe736 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:24:12 +0200
Subject: [PATCH 017/134] ci: add Linux and Windows regression checks and
benchmark smoke
---
.github/workflows/r-tests.yml | 49 +++++++++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 .github/workflows/r-tests.yml
diff --git a/.github/workflows/r-tests.yml b/.github/workflows/r-tests.yml
new file mode 100644
index 0000000..9891192
--- /dev/null
+++ b/.github/workflows/r-tests.yml
@@ -0,0 +1,49 @@
+name: MAniR tests and benchmark smoke
+
+on:
+ push:
+ branches:
+ - main
+ - upgrade/**
+ pull_request:
+
+jobs:
+ r-tests:
+ name: R regression suite
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: r-lib/actions/setup-r@v2
+ with:
+ use-public-rspm: true
+ - uses: r-lib/actions/setup-r-dependencies@v2
+ with:
+ cache-version: 1
+ extra-packages: |
+ any::data.table
+ any::fastcluster
+ - name: Test numerical correctness, files and plot export
+ run: Rscript tests/run_tests.R
+ - name: Parse and load the Shiny application
+ run: Rscript -e "stopifnot(inherits(source('app.R')$value, 'shiny.appobj'))"
+ - name: Benchmark smoke
+ run: Rscript benchmarks/benchmark.R --smoke
+ - name: Upload benchmark smoke results
+ uses: actions/upload-artifact@v4
+ with:
+ name: MAniR-benchmark-smoke
+ path: benchmarks/results/*-smoke.csv
+
+ windows-tests:
+ name: Windows regression suite
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: r-lib/actions/setup-r@v2
+ - uses: r-lib/actions/setup-r-dependencies@v2
+ with:
+ cache-version: 1
+ - name: Regression tests
+ run: Rscript tests/run_tests.R
+ - name: Parse and load application
+ run: Rscript -e "stopifnot(inherits(source('app.R')$value, 'shiny.appobj'))"
From 59a71681c4047aa17256037a680e951a538039f5 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:25:22 +0200
Subject: [PATCH 018/134] feat: add large-matrix region navigation controls
---
ui.R | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/ui.R b/ui.R
index 1b0bcc0..3754636 100644
--- a/ui.R
+++ b/ui.R
@@ -49,6 +49,10 @@ ui <- shiny::fluidPage(
choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
"Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
selected = "RdBu"),
+ shiny::checkboxInput("zoom_enabled", "Zoom into a region in large matrices", FALSE),
+ shiny::numericInput("zoom_start", "First isolate index", 1, min = 1, step = 1),
+ shiny::numericInput("zoom_size", "Isolates in zoom region", 300,
+ min = 10, max = 1000, step = 50),
shiny::checkboxInput("show_numbers", "Display cell numbers in small plots",
value = FALSE),
shiny::checkboxInput("log_scale", "Logarithmic display scaling",
From 9b84ff9a55d644efb7bb490b9c2293c750e5d761 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:25:24 +0200
Subject: [PATCH 019/134] perf: render viewport slices without allocating
complete large combined matrices
---
server.R | 72 +++++++++++++++++++++++++++++++++++++++-----------------
1 file changed, 50 insertions(+), 22 deletions(-)
diff --git a/server.R b/server.R
index c8c4b5b..27c74ff 100644
--- a/server.R
+++ b/server.R
@@ -155,11 +155,20 @@ server <- function(input, output, session) {
list(first = d$second[ids, ids, drop = FALSE],
second = d$first[ids, ids, drop = FALSE])
})
+ # Keep source matrices separate until a particular viewport is requested.
diff_matrix <- shiny::reactive({
shiny::req(input$comparable_scales)
- x <- combined_first()
- x$first - x$second
+ combined_first()
})
+ visible_indices <- function(n) {
+ ix <- if (isTRUE(input$zoom_enabled) && n > 300L) {
+ start <- max(1L, min(n, as.integer(input$zoom_start)))
+ seq.int(start, min(n, start + as.integer(input$zoom_size) - 1L))
+ } else seq_len(n)
+ if (length(ix) > 512L)
+ ix <- ix[unique(as.integer(round(seq(1, length(ix), length.out = 512L))))]
+ ix
+ }
pair_data <- shiny::reactive({
m <- shared()
paired_values(m$first, m$second, max_pairs = input$max_pairs)
@@ -218,7 +227,7 @@ server <- function(input, output, session) {
output[[paste0(prefix, "_ui")]] <- shiny::renderUI({
if (is_difference) shiny::req(input$comparable_scales)
m <- source()
- size <- if (combined) nrow(m$first) else nrow(m)
+ size <- if (combined || is_difference) nrow(m$first) else nrow(m)
if (size <= 300L)
plotly::plotlyOutput(paste0(prefix, "_interactive"),
width = "100%", height = "750px")
@@ -232,8 +241,9 @@ server <- function(input, output, session) {
output[[paste0(prefix, "_interactive")]] <- plotly::renderPlotly({
if (is_difference) shiny::req(input$comparable_scales)
m <- source()
- size <- if (combined) nrow(m$first) else nrow(m)
+ size <- if (combined || is_difference) nrow(m$first) else nrow(m)
shiny::req(size <= 300L)
+ if (is_difference) m <- m$first - m$second
ma_interactive(m, name = name, palette = input$palette,
show_numbers = input$show_numbers, combined = combined,
first_name = first_label, second_name = second_label,
@@ -242,38 +252,56 @@ server <- function(input, output, session) {
output[[paste0(prefix, "_raster")]] <- shiny::renderPlot({
if (is_difference) shiny::req(input$comparable_scales)
m <- source()
- size <- if (combined) nrow(m$first) else nrow(m)
+ size <- if (combined || is_difference) nrow(m$first) else nrow(m)
shiny::req(size > 300L)
- if (combined) {
- m <- ma_combined(m$first, m$second, input$log_scale)$values
- normal <- TRUE
- } else normal <- FALSE
+ ix <- visible_indices(size)
+ if (combined || is_difference) {
+ # Slice first: do not build three full N*N combined copies for a preview.
+ a <- m$first[ix, ix, drop = FALSE]
+ b <- m$second[ix, ix, drop = FALSE]
+ if (combined) {
+ m <- ma_combined(a, b, input$log_scale)$values
+ normal <- TRUE
+ } else {
+ m <- a - b
+ normal <- FALSE
+ }
+ } else {
+ m <- m[ix, ix, drop = FALSE]
+ normal <- FALSE
+ }
graphics::par(mar = c(9, 9, 5, 2))
d <- data()
meta <- if (is_difference || !nzchar(input$metadata_column)) NULL
else d$metadata
ma_raster(m, palette = input$palette, title = name,
- metadata = meta,
- group_column = input$metadata_column, normalized = normal,
- log_scale = input$log_scale && !is_difference)
+ metadata = meta, group_column = input$metadata_column,
+ normalized = normal, log_scale = input$log_scale && !is_difference)
+ if (length(ix) < size)
+ graphics::mtext(if (isTRUE(input$zoom_enabled))
+ sprintf("Zoom region; %d of %d isolates (starting at %d).",
+ length(ix), size, min(ix))
+ else sprintf("Representative overview: %d of %d isolates. Zoom for exact detail.",
+ length(ix), size), side = 3, line = 0.1, cex = 0.75)
}, res = 110)
output[[paste0(prefix, "_cell")]] <- shiny::renderText({
shiny::req(input[[paste0(prefix, "_click")]])
if (is_difference) shiny::req(input$comparable_scales)
m <- source()
- original <- if (combined)
- ma_combined(m$first, m$second, input$log_scale)$original else m
- preview <- ma_preview(original)
+ base <- if (combined || is_difference) m$first else m
+ ix <- visible_indices(nrow(base))
click <- input[[paste0(prefix, "_click")]]
col <- as.integer(round(click$x))
- row <- as.integer(nrow(preview$matrix) + 1 - round(click$y))
+ row <- as.integer(length(ix) + 1L - round(click$y))
if (is.na(col) || is.na(row) || col < 1L ||
- row < 1L || col > nrow(preview$matrix) ||
- row > nrow(preview$matrix)) return("")
- i <- preview$indices[row]
- j <- preview$indices[col]
- sprintf("%s / %s: %s%s", rownames(original)[i],
- colnames(original)[j], format(original[i, j], digits = 9),
+ row < 1L || col > length(ix) || row > length(ix)) return("")
+ i <- ix[row]
+ j <- ix[col]
+ value <- if (combined && i > j) m$second[i, j]
+ else if (is_difference) m$first[i, j] - m$second[i, j]
+ else if (combined) m$first[i, j] else m[i, j]
+ sprintf("%s / %s: %s%s", rownames(base)[i],
+ colnames(base)[j], format(value, digits = 9),
if (combined) if (i < j) paste0(" (", first_label, ")")
else if (i > j) paste0(" (", second_label, ")")
else " (diagonal)" else "")
From d70864824cb036e4b698cfa7564a6dfee8df9234 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:26:11 +0200
Subject: [PATCH 020/134] docs: document upgraded MAniR workflow, large-matrix
limits and scientific assumptions
---
README.md | 134 +++++++++++++++++++++++++++++-------------------------
1 file changed, 73 insertions(+), 61 deletions(-)
diff --git a/README.md b/README.md
index 5d13545..344b946 100644
--- a/README.md
+++ b/README.md
@@ -1,73 +1,85 @@
# MAniR
-Originally this app was develloped for comparison of ANI and MALDI analysis. The app is now generalized to visualize the results from correlation analysis.
+**Interactive comparison of pairwise similarity matrices for MALDI-TOF and genomics.**
-MAniR is an R shiny app that visualizes the results from correlation analysis. The app can be used to visualize the results from the analysis of a single sample or multiple samples by way of correlation plots. Initially the app was developed to visualize the results from the ANI and MALDI analysis. These are methods to determine the relatedness of bacterial strains. The app can now be used to visualize the results from any correlation analysis.
+MAniR is an R Shiny application for investigating pairwise relationships between bacterial isolates, or other samples for which symmetric pairwise matrices are available. It began as an ANI-versus-MALDI comparison application. The upgrade adds numerical matrix comparison, cluster concordance, a large-dataset raster viewer, metadata annotations, and reusable analysis functions.
-## Original background information
+This development branch is **MAniR 3.0 prerelease**. The historical application remains available in the repository's Git history and on `main` until the upgrade is reviewed. Do not cite an unverified performance improvement: the benchmark scripts need to be run on specified hardware.
-ANI analysis is a method to determine the relatedness of bacterial strains. The method is based on the analysis of the genome of the bacteria. The genome is divided into fragments and the similarity of the fragments is determined.
+## Installation
-MALDI analysis is a method to determine the presence of bacteria in a sample. The method is based on the analysis of the proteins of the bacteria. The proteins are separated by mass and the mass of the proteins is determined. These can also be compared between each other to determine the relatedness of the bacteria.
+Install R 4.2 or later. In the repository directory, run:
-MAniR is a tool to visualize the results from both the ANI and MALDI analysis. The tool is based on the R shiny framework.
-### Example of the MAniR app, single matrix output
-
+```r
+install.packages(c("shiny", "plotly", "openxlsx", "RColorBrewer"))
+# Optional accelerators (recommended for large datasets):
+install.packages(c("data.table", "fastcluster"))
+shiny::runApp(".")
+```
-### Example of comparisson of matrices output
-
+For scripted validation, run `Rscript tests/run_tests.R`. For repeatable performance measurements, run `Rscript benchmarks/benchmark.R` and retain the generated CSV. The optional `--smoke` flag runs a short check; `--large` includes 10,000-isolate data and requires substantial RAM.
-## Installation
-MAniR can be installed by cloning this repository and running the following command in the root directory of the repository:
+## Input formats
-```r
-R -e "shiny::runApp()"
+- **XLSX / XLSM:** one matrix per worksheet, with sample IDs in the first row and first column. Optional second matrix and metadata sheets may be selected from the same workbook. A second workbook is also supported.
+- **CSV / TSV / gzip-compressed CSV or TSV:** sample IDs in the first column, sample names in the header, and numeric pairwise measurements in the remaining cells. A second matrix and metadata may be uploaded separately.
+- **RDS:** an R numeric matrix or data frame with row and column sample IDs.
+- **Metadata:** a separate Excel worksheet, CSV, TSV or RDS table with sample IDs as row names (or the first column for a text file). Categorical metadata can be displayed as a color strip for large raster views and used for descriptive within-group versus between-group comparisons.
+- **Precomputed sample order:** optional plain-text file listing one isolate ID per line. Any remaining isolates are appended in their original order. This avoids recomputing clustering for larger projects.
+
+Every matrix must be square, numeric and symmetric; row and column IDs must be identical as sets. The importer trims outer whitespace and automatically aligns rows to column order. Duplicate identifiers, infinite values, invalid matrix ranges, asymmetric entries and asymmetric missingness are reported. Missing matrix pairs may be imported for visualization, but cannot be used in the Mantel permutation test.
+
+Select what each matrix means: similarity (nonnegative; larger is more similar), correlation (range -1 to 1) or distance (nonnegative; smaller is more similar). "Unspecified" permits a general numeric symmetric matrix and interprets larger entries as more similar when clustering.
+
+For two matrices, choose whether the isolate lists must match exactly or whether to compare their shared intersection. Unmatched isolates remain available in the individual matrix tabs and are reported in the overview.
+
+## Visualization and large-matrix handling
+
+Smaller matrices (300 isolates or fewer) are displayed as interactive Plotly heatmaps with exact hover values. Number overlays are available for at most 70 isolates. For larger matrices, the default raster viewer displays up to 512 representative rows and columns rather than creating a browser annotation for every cell. This is a **representative overview**, not an aggregated matrix or a numerical substitute for the source data. Click a displayed cell to inspect the exact underlying matrix entry.
+
+Enable "Zoom into a region" and enter the first isolate index and the region size to inspect adjacent samples. When a region exceeds 512 isolates, it is itself reduced to a representative 512-by-512 display; choose a smaller region to inspect every cell.
+
+The combined views use the first matrix above the diagonal and the second below the diagonal. One view uses the sample ordering from matrix 1; the other uses the ordering from matrix 2. Because ANI percentages and MALDI similarities can have different units, **each triangle is display-scaled independently**. Hover and click inspections, pairwise exports and RDS downloads retain the original numeric values. Log display scaling requires positive finite values.
+
+Clustering defaults to average-linkage hierarchical clustering. Large matrices above 2,000 isolates skip clustering by default to avoid excessive computation and memory use. Supply a precomputed ordering if desired. The original numerical matrices are not altered by clustering.
+
+## Research features
+
+The pairwise comparison tab plots values from corresponding unordered isolate pairs, excluding the diagonal. It reports Pearson and Spearman correlation coefficients. If the matrices truly use comparable units and the corresponding checkbox is enabled, it also reports mean absolute and root mean squared differences, a one-to-one reference line and the isolate pairs with the largest absolute differences. Treat these statistics as descriptive; isolate-pair observations are not statistically independent.
+
+To bound memory use, the pairwise tool examines up to 100,000 distinct pairs by default. When the full dataset exceeds this limit, it selects a reproducible random subset (fixed seed 1), discloses the number of evaluated pairs and marks the analysis as sampled. Downloaded pairwise values follow the same selection. Change the pair limit in the sidebar for a larger subset.
+
+The Mantel test permutes isolate labels jointly in one matrix. It reports a two-sided permutation p-value using `(extreme + 1) / (permutations + 1)`. The current implementation requires complete matched matrices with at most 400 isolates. The Mantel statistic measures matrix association; it does not establish that two typing methods produce equivalent classifications.
+
+Cluster comparison creates average-linkage group assignments on shared isolates and reports the adjusted Rand index and the directional adjusted Wallace coefficients. The number of clusters is user-selectable and both matrices must have at most 2,000 shared isolates. Pairwise similarities and clustering distances are kept conceptually distinct. The metadata group summary describes within-group versus between-group pairwise values, which may be helpful for MALDI repeatability or batch studies. That descriptive group comparison is not a formal batch-effect test.
+
+MAniR accepts **precomputed** MALDI-TOF, ANI, dDDH and other pairwise results. Raw peak detection, spectral calibration and alignment remain the responsibility of specialized preprocessing software. Feeding differently normalized matrices into MAniR does not make the underlying measurement scales interchangeable.
+
+## Export and reproducibility
+
+The export tab provides exact input matrices as CSV, an RDS bundle containing both matrices, optional metadata and sample order, static PNGs and a text manifest of analysis settings and R session information. Pairwise comparison and cluster assignments have their own CSV exports. Large-image exports use representative raster views with up to 1,200 rows and columns, not an exact full-resolution image of a 10,000-isolate matrix.
+
+To use MAniR reproducibly, archive the source matrices, the metadata, the settings manifest, the installed R package versions, the generated plots and the resulting tables. Never infer a biological cutoff from the normalized heatmap colors; inspect the original numeric values and justify cutoffs from the relevant study.
+
+## Project structure
+
+```text
+app.R Shiny entry point
+ui.R Shiny controls, linked views and downloads
+server.R Session-specific upload, analysis and lazy render logic
+R/matrix_io.R Format readers, sample ID validation and alignment
+R/matrix_analysis.R Pairwise summaries, clustering, permutations
+R/matrix_plot.R Interactive and sampled raster rendering
+tests/run_tests.R Numeric, import, statistics and rendering regressions
+benchmarks/benchmark.R Repeated staged scaling measurements
+docs/UPGRADE_WORKLOG.md Implementation scope and release criteria
+.github/workflows/r-tests.yml Linux and Windows checks
```
-## Usage
-The input should be provided in the form of an xlsx file. The file should contain the data in the following format:
-_|_bacteria1_|_bacteria2_|_bacteria3_|_bacteria4_|_bacteria5_|
-|---|---|---|---|---|---|
-|_bacteria1_|_100_|_98.5_|_97.5_|_96.5_|_95.5_|
-|_bacteria2_|_98.5_|_100_|_98.5_|_97.5_|_96.5_|
-|_bacteria3_|_97.5_|_98.5_|_100_|_98.5_|_97.5_|
-|_bacteria4_|_96.5_|_97.5_|_98.5_|_100_|_98.5_|
-|_bacteria5_|_95.5_|_96.5_|_97.5_|_98.5_|_100_|
-
-*Note that the diagonal of the matrix should contain the values 100. The values in the matrix should be the ANI values between the bacteria. The values should be between 0 and 100. There should be as many rows and columns as there are bacteria in the sample. The bacteria should be named in the first row and column.*
-
-If you want to perform a combined visualization of the ANI and MALDI analysis, you should make sure that the bacteria are named the same in the ANI and MALDI data and all bacteria are present in both datasets.
-
-## Options
-The following options can be set in the app:
-* **Worksheet with ANI values**: The worksheet in the xlsx file that contains the ANI values. When an excel file is loaded, the worksheets in the file are listed in the dropdown menu.
-* **Worksheet with MALDI values**: The worksheet in the xlsx file that contains the MALDI values. When an excel file is loaded, the worksheets in the file are listed in the dropdown menu.
-
--- advanced options --
-
-* **Decimal places**: The number of decimal places to show in the correlation plot.
-* **Size of numbers in the plot**: The size of the numbers in the correlation plot.
-* **Hide colorscale**: Hide the colorscale in the correlation plot. Default value is True.
-* **Show dendrograms**: Show dendrograms in the correlation plot. Default value is False.
-* **Show numbers**: Show numbers in the correlation plot. Default value is True.
-* **Log scale**: Use a log scale for the correlation plot. Default value is False. This might improve the visualization of the correlation plot when the values are very different.
-
--- advanced options - Legacy mode --
-
-
-* **Decimal places**: The number of decimal places to show in the correlation plot.
-* **Size of numbers in the plot**: The size of the numbers in the correlation plot.
-* **Scale of the plot**: The scale of the plot, this will make the plot larger or smaller. The default value of 1 means that the output plots will be 1000 by 1000 pixels.
-* **Size of the ANI/MALDI labels**: The size of the ANI/MALDI labels in the combined correlation plot.
-* **Color mode**: The color mode of the correlation plot. The default value is 'diverging', the other option is 'sequential'.
-* **Colorscale**: Based on the color mode, different color pallets can be selected. The default value is 'RdBu' for the 'diverging' color mode and 'YlOrRd' for the 'sequential' color mode.
-
-### Diverging color pallets:
-
-### Sequential color pallets:
-
-
-## Download tab
-The download tab allows you to download the plots as html files. The plots can be opened in a browser later on. Saving as a png can be done by clicking the export button on the interactive plot.
-
-**Note:** Legacy mode is not supported in the download tab. To download the plots in legacy mode, right click on the plot and select 'Save as image'.
+## Existing use
+
+MAniR was used to visualize genomic similarity comparisons in Versmessen et al. (2024), *Diagnostics*, 14, 1800, ["Average Nucleotide Identity and Digital DNA-DNA Hybridization Analysis Following PromethION Nanopore-Based Whole Genome Sequencing Allows for Accurate Prokaryotic Typing"](https://doi.org/10.3390/diagnostics14161800). The original tool was designed for ANI and MALDI analyses and later generalized to symmetric pairwise data.
+
+## License
+
+GPL-3.0, as in the original repository.
From aebe1783fae8612be9d4b41934cedff7542071c9 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:26:56 +0200
Subject: [PATCH 021/134] feat: calculate directional adjusted Wallace cluster
agreement
---
R/matrix_analysis.R | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/R/matrix_analysis.R b/R/matrix_analysis.R
index cfcfa80..1d9d68c 100644
--- a/R/matrix_analysis.R
+++ b/R/matrix_analysis.R
@@ -140,6 +140,19 @@ adjusted_rand <- function(labels_a, labels_b) {
(nij - expected) / (maximum - expected)
}
+
+adjusted_wallace <- function(labels_a, labels_b) {
+ if (length(labels_a) != length(labels_b) || length(labels_a) < 2L)
+ stop("Cluster assignments must refer to the same isolates.")
+ tab <- table(labels_a, labels_b)
+ pairs <- function(x) sum(x * (x - 1) / 2)
+ same_in_both <- pairs(tab)
+ same_in_first <- pairs(rowSums(tab))
+ expected_b <- pairs(colSums(tab)) / choose(length(labels_a), 2)
+ if (same_in_first == 0 || expected_b >= 1) return(NA_real_)
+ (same_in_both / same_in_first - expected_b) / (1 - expected_b)
+}
+
cluster_concordance <- function(a, b, kind_a = "similarity",
kind_b = "similarity", k = 3L,
max_n = 2000L) {
@@ -152,6 +165,8 @@ cluster_concordance <- function(a, b, kind_a = "similarity",
first <- stats::cutree(ca$tree, k = k)
second <- stats::cutree(cb$tree, k = k)
list(ari = adjusted_rand(first, second),
+ adjusted_wallace_1_to_2 = adjusted_wallace(first, second),
+ adjusted_wallace_2_to_1 = adjusted_wallace(second, first),
assignments = data.frame(sample_id = rownames(a),
first = unname(first[rownames(a)]),
second = unname(second[rownames(a)])),
From 2ec74c38861375602b749b3ffe717ffde7472573 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:26:58 +0200
Subject: [PATCH 022/134] feat: report directional Wallace concordance for
MALDI and genomic clusters
---
server.R | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/server.R b/server.R
index 27c74ff..2d9464e 100644
--- a/server.R
+++ b/server.R
@@ -349,7 +349,11 @@ server <- function(input, output, session) {
})
output$cluster_summary <- shiny::renderTable({
c <- concordance()
- data.frame(measure = "Adjusted Rand index", value = c$ari)
+ data.frame(measure = c("Adjusted Rand index",
+ "Adjusted Wallace (matrix 1 -> matrix 2)",
+ "Adjusted Wallace (matrix 2 -> matrix 1)"),
+ value = c(c$ari, c$adjusted_wallace_1_to_2,
+ c$adjusted_wallace_2_to_1))
})
output$cluster_table <- shiny::renderTable({
utils::head(concordance()$assignments, 100L)
From 26ac9beb084a1c4b97b04b840615e0fbc9a726b5 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:27:02 +0200
Subject: [PATCH 023/134] test: assert directional Wallace values for
equivalent clusterings
---
tests/run_tests.R | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 2f25990..0d21b80 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -112,8 +112,12 @@ test("cluster ordering and adjusted Rand index are reproducible", {
stopifnot(identical(c1$ids, c2$ids),
identical(sort(c1$ids), sort(ids)),
adjusted_rand(c(1, 1, 2, 2), c(2, 2, 1, 1)) == 1)
+ stopifnot(abs(adjusted_wallace(c(1, 1, 2, 2),
+ c(2, 2, 1, 1)) - 1) < 1e-12)
result <- cluster_concordance(m, m, k = 2)
stopifnot(abs(result$ari - 1) < 1e-12,
+ abs(result$adjusted_wallace_1_to_2 - 1) < 1e-12,
+ abs(result$adjusted_wallace_2_to_1 - 1) < 1e-12,
nrow(result$assignments) == 4L)
skip <- matrix_order(m, max_cluster = 2L)
stopifnot(isTRUE(skip$skipped), is.null(skip$tree))
From 7bd7a73c0f2c5abbdd9b118a39d265f2a00b8b69 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:28:52 +0200
Subject: [PATCH 024/134] perf: validate numeric matrices in blocks without
multiple full-matrix copies
---
R/matrix_io.R | 55 ++++++++++++++++++++++++++++++++++-----------------
1 file changed, 37 insertions(+), 18 deletions(-)
diff --git a/R/matrix_io.R b/R/matrix_io.R
index 6aa53f7..d48c336 100644
--- a/R/matrix_io.R
+++ b/R/matrix_io.R
@@ -6,7 +6,8 @@ matrix_sheet_names <- function(path, format = path) {
character()
}
-read_matrix_input <- function(path, sheet = NULL, workbook = NULL, format = path) {
+read_matrix_input <- function(path, sheet = NULL, workbook = NULL, format = path,
+ kind = "auto") {
ext <- tolower(tools::file_ext(format))
if (ext %in% c("xlsx", "xlsm")) {
if (is.null(sheet) || identical(sheet, "")) stop("Select a matrix worksheet.")
@@ -32,7 +33,7 @@ read_matrix_input <- function(path, sheet = NULL, workbook = NULL, format = path
} else {
stop("Unsupported matrix format. Use XLSX, CSV, TSV, CSV.GZ, TSV.GZ or RDS.")
}
- validate_matrix(x)
+ validate_matrix(x, kind = kind)
}
validate_matrix <- function(x, kind = c("auto", "similarity", "correlation", "distance"),
@@ -58,27 +59,45 @@ validate_matrix <- function(x, kind = c("auto", "similarity", "correlation", "di
paste(head(only_rows, 10L), collapse = ", "),
paste(head(only_cols, 10L), collapse = ", ")))
}
- original <- as.matrix(x)
- converted <- suppressWarnings(as.numeric(original))
- empty <- is.na(original) | trimws(as.character(original)) == ""
- if (any(is.na(converted) & !empty))
- stop("The matrix has nonnumeric values. Check the data worksheets.")
- m <- matrix(converted, nrow = nrow(x), ncol = ncol(x),
- dimnames = list(row_ids, col_ids))
- m <- m[col_ids, col_ids, drop = FALSE]
+ if (is.matrix(x) && is.numeric(x)) {
+ # Fast path: avoid coercing an already numeric N*N matrix into another
+ # full-size matrix during import.
+ m <- x
+ dimnames(m) <- list(row_ids, col_ids)
+ } else {
+ original <- as.matrix(x)
+ converted <- suppressWarnings(as.numeric(original))
+ empty <- is.na(original) | trimws(as.character(original)) == ""
+ if (any(is.na(converted) & !empty))
+ stop("The matrix has nonnumeric values. Check the data worksheets.")
+ m <- matrix(converted, nrow = nrow(x), ncol = ncol(x),
+ dimnames = list(row_ids, col_ids))
+ }
+ # Reorder only when necessary; with 10k isolates, one copy costs 800 MB.
+ if (!identical(row_ids, col_ids))
+ m <- m[col_ids, col_ids, drop = FALSE]
if (any(is.infinite(m))) stop("Infinite matrix values are not supported.")
if (!allow_missing && anyNA(m)) stop("Missing matrix values are not allowed.")
if (anyNA(diag(m))) stop("Every diagonal entry must be present.")
- if (!identical(is.na(m), is.na(t(m))))
- stop("Missing values must occur in mirrored pairs for a symmetric matrix.")
- numeric_diff <- abs(m - t(m))
- if (any(numeric_diff > symmetry_tolerance, na.rm = TRUE))
- stop("The matrix must be symmetric; mirrored cells disagree.")
- if (kind == "correlation" && any(m < -1 - symmetry_tolerance |
- m > 1 + symmetry_tolerance, na.rm = TRUE))
+ # Verify exact symmetry in blocks to avoid allocating N*N transposed,
+ # missingness and difference matrices simultaneously.
+ n <- nrow(m)
+ block_size <- 256L
+ for (start in seq.int(1L, n, by = block_size)) {
+ ix <- seq.int(start, min(n, start + block_size - 1L))
+ left <- m[ix, , drop = FALSE]
+ right <- t(m[, ix, drop = FALSE])
+ if (!identical(is.na(left), is.na(right)))
+ stop("Missing values must occur in mirrored pairs for a symmetric matrix.")
+ if (any(abs(left - right) > symmetry_tolerance, na.rm = TRUE))
+ stop("The matrix must be symmetric; mirrored cells disagree.")
+ }
+ if (kind == "correlation" &&
+ (min(m, na.rm = TRUE) < -1 - symmetry_tolerance ||
+ max(m, na.rm = TRUE) > 1 + symmetry_tolerance))
stop("Correlation values must lie between -1 and 1.")
if (kind %in% c("similarity", "distance") &&
- any(m < -symmetry_tolerance, na.rm = TRUE))
+ min(m, na.rm = TRUE) < -symmetry_tolerance)
stop("Similarity and distance matrices cannot contain negative values.")
attr(m, "matrix_kind") <- kind
m
From 47f0a9435411604f1ccfa8f1e2b6691ba0a95b56 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:28:56 +0200
Subject: [PATCH 025/134] perf: reuse aligned source matrices and validate
uploaded data only once
---
server.R | 22 ++++++++++++----------
1 file changed, 12 insertions(+), 10 deletions(-)
diff --git a/server.R b/server.R
index 2d9464e..7be2136 100644
--- a/server.R
+++ b/server.R
@@ -41,8 +41,7 @@ server <- function(input, output, session) {
wb1 <- if (is_excel(f)) openxlsx::loadWorkbook(f$datapath) else NULL
a <- read_matrix_input(f$datapath,
sheet = if (is_excel(f)) input$first_sheet else NULL,
- workbook = wb1, format = f$name)
- a <- validate_matrix(a, input$kind1)
+ workbook = wb1, format = f$name, kind = input$kind1)
shiny::incProgress(0.20)
f2 <- if (!is.null(input$second_file)) input$second_file else f
has_second <- !is.null(input$second_file) ||
@@ -57,8 +56,7 @@ server <- function(input, output, session) {
openxlsx::loadWorkbook(f2$datapath) else wb1
b <- read_matrix_input(f2$datapath,
sheet = if (is_excel(f2)) input$second_sheet else NULL,
- workbook = wb2, format = f2$name)
- b <- validate_matrix(b, input$kind2)
+ workbook = wb2, format = f2$name, kind = input$kind2)
# Match once for early actionable errors; avoid retaining copies.
shared_ids <- intersect(colnames(a), colnames(b))
if (input$match_mode == "strict" &&
@@ -127,14 +125,18 @@ server <- function(input, output, session) {
data <- shiny::reactive({ shiny::req(loaded()); loaded() })
has_two <- shiny::reactive(!is.null(data()$second))
+ subset_if_needed <- function(m, ids) {
+ if (identical(colnames(m), ids) && identical(rownames(m), ids))
+ m else m[ids, ids, drop = FALSE]
+ }
ordered_first <- shiny::reactive({
d <- data()
- d$first[d$order1, d$order1, drop = FALSE]
+ subset_if_needed(d$first, d$order1)
})
ordered_second <- shiny::reactive({
d <- data()
shiny::req(d$second)
- d$second[d$order2, d$order2, drop = FALSE]
+ subset_if_needed(d$second, d$order2)
})
shared <- shiny::reactive({
d <- data()
@@ -145,15 +147,15 @@ server <- function(input, output, session) {
d <- data()
both <- shared()
ids <- d$order1[d$order1 %in% both$ids]
- list(first = d$first[ids, ids, drop = FALSE],
- second = d$second[ids, ids, drop = FALSE])
+ list(first = subset_if_needed(d$first, ids),
+ second = subset_if_needed(d$second, ids))
})
combined_second <- shiny::reactive({
d <- data()
both <- shared()
ids <- d$order2[d$order2 %in% both$ids]
- list(first = d$second[ids, ids, drop = FALSE],
- second = d$first[ids, ids, drop = FALSE])
+ list(first = subset_if_needed(d$second, ids),
+ second = subset_if_needed(d$first, ids))
})
# Keep source matrices separate until a particular viewport is requested.
diff_matrix <- shiny::reactive({
From b201d68d1130aabed74559a3010a413bab0d8d00 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:29:04 +0200
Subject: [PATCH 026/134] perf: reuse source matrices when matched sample
ordering already agrees
---
R/matrix_io.R | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/R/matrix_io.R b/R/matrix_io.R
index d48c336..2302537 100644
--- a/R/matrix_io.R
+++ b/R/matrix_io.R
@@ -142,8 +142,12 @@ match_matrices <- function(first, second, mode = c("strict", "intersection")) {
length(setdiff(ids1, ids2)), length(setdiff(ids2, ids1))))
ids <- if (mode == "strict") ids1 else intersect(ids1, ids2)
if (length(ids) < 2L) stop("At least two shared sample IDs are required.")
- list(first = first[ids, ids, drop = FALSE],
- second = second[ids, ids, drop = FALSE],
+ list(first = if (identical(ids, rownames(first)) &&
+ identical(ids, colnames(first))) first
+ else first[ids, ids, drop = FALSE],
+ second = if (identical(ids, rownames(second)) &&
+ identical(ids, colnames(second))) second
+ else second[ids, ids, drop = FALSE],
ids = ids,
only_first = setdiff(ids1, ids2),
only_second = setdiff(ids2, ids1))
From 679b940d5174bac7a402b8aee9373193ae9dbbd7 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:29:21 +0200
Subject: [PATCH 027/134] fix: center difference-map colors on zero without
changing data
---
R/matrix_plot.R | 24 +++++++++++++++---------
1 file changed, 15 insertions(+), 9 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 33ad946..20e563e 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -15,7 +15,7 @@ ma_colors <- function(palette = "RdBu", n = 256L, reverse = FALSE) {
grDevices::colorRampPalette(base)(n)
}
-ma_scale <- function(x, log_scale = FALSE) {
+ma_scale <- function(x, log_scale = FALSE, center_zero = FALSE) {
original <- x
valid <- is.finite(x)
if (!any(valid)) stop("There are no finite values to display.")
@@ -23,11 +23,17 @@ ma_scale <- function(x, log_scale = FALSE) {
if (any(x[valid] <= 0)) stop("Log scaling requires strictly positive values.")
x[valid] <- log(x[valid])
}
- rng <- range(x[valid])
- if (diff(rng) == 0) {
- x[valid] <- 0.5
+ if (center_zero) {
+ if (log_scale) stop("Zero-centered scaling cannot use logarithms.")
+ radius <- max(abs(x[valid]))
+ x[valid] <- if (radius == 0) 0.5 else 0.5 + 0.5 * x[valid] / radius
} else {
- x[valid] <- (x[valid] - rng[1L]) / diff(rng)
+ rng <- range(x[valid])
+ if (diff(rng) == 0) {
+ x[valid] <- 0.5
+ } else {
+ x[valid] <- (x[valid] - rng[1L]) / diff(rng)
+ }
}
x[!valid] <- NA_real_
attr(x, "original_range") <- range(original[valid])
@@ -54,7 +60,7 @@ ma_combined <- function(first, second, log_scale = FALSE) {
ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
show_numbers = FALSE, combined = FALSE,
first_name = "Matrix 1", second_name = "Matrix 2",
- log_scale = FALSE) {
+ log_scale = FALSE, center_zero = FALSE) {
if (!requireNamespace("plotly", quietly = TRUE))
stop("Install the plotly package for interactive plots.")
n <- if (combined) nrow(m$first) else nrow(m)
@@ -65,7 +71,7 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
original <- norm$original
} else {
original <- m
- z <- ma_scale(m, log_scale)
+ z <- ma_scale(m, log_scale, center_zero)
}
ids <- rownames(original)
hover <- matrix("", nrow = n, ncol = n)
@@ -115,10 +121,10 @@ ma_preview <- function(m, max_side = 512L) {
ma_raster <- function(m, palette = "RdBu", title = "",
max_side = 512L, log_scale = FALSE,
metadata = NULL, group_column = NULL,
- normalized = FALSE) {
+ normalized = FALSE, center_zero = FALSE) {
preview <- ma_preview(m, max_side)
x <- preview$matrix
- z <- if (normalized) x else ma_scale(x, log_scale)
+ z <- if (normalized) x else ma_scale(x, log_scale, center_zero)
cols <- ma_colors(palette)
values <- as.integer(1 + round(z * (length(cols) - 1L)))
color_matrix <- matrix(NA_character_, nrow(z), ncol(z))
From e19d4d6e135fc6f3f71cefd81304b0436bcc67d8 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:29:23 +0200
Subject: [PATCH 028/134] perf: bound combined PNG export and use zero-centered
difference maps
---
server.R | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/server.R b/server.R
index 7be2136..ba3d08e 100644
--- a/server.R
+++ b/server.R
@@ -249,7 +249,8 @@ server <- function(input, output, session) {
ma_interactive(m, name = name, palette = input$palette,
show_numbers = input$show_numbers, combined = combined,
first_name = first_label, second_name = second_label,
- log_scale = input$log_scale && !is_difference)
+ log_scale = input$log_scale && !is_difference,
+ center_zero = is_difference)
})
output[[paste0(prefix, "_raster")]] <- shiny::renderPlot({
if (is_difference) shiny::req(input$comparable_scales)
@@ -278,7 +279,8 @@ server <- function(input, output, session) {
else d$metadata
ma_raster(m, palette = input$palette, title = name,
metadata = meta, group_column = input$metadata_column,
- normalized = normal, log_scale = input$log_scale && !is_difference)
+ normalized = normal, log_scale = input$log_scale && !is_difference,
+ center_zero = is_difference)
if (length(ix) < size)
graphics::mtext(if (isTRUE(input$zoom_enabled))
sprintf("Zoom region; %d of %d isolates (starting at %d).",
@@ -421,10 +423,15 @@ server <- function(input, output, session) {
filename = function() "MAniR_combined.png",
content = function(file) {
m <- combined_first()
- ma_save_png(ma_combined(m$first, m$second, input$log_scale)$values,
- file, palette = input$palette,
- title = "Combined matrix, independently display-scaled",
- normalized = TRUE)
+ # Export no more than 1,200 representative isolates to bound memory,
+ # then calculate display normalization on that exact exported subset.
+ ix <- ma_preview(m$first, max_side = 1200L)$indices
+ a <- m$first[ix, ix, drop = FALSE]
+ b <- m$second[ix, ix, drop = FALSE]
+ shown <- ma_combined(a, b, input$log_scale)$values
+ ma_save_png(shown, file, palette = input$palette,
+ title = sprintf("Combined matrix, %d of %d isolates", length(ix),
+ nrow(m$first)), normalized = TRUE)
})
output$download_settings <- shiny::downloadHandler(
filename = function() "MAniR_analysis_settings.txt",
From b3b79eb9c4e6b7f883019f6204e35efab33fda0f Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:29:27 +0200
Subject: [PATCH 029/134] test: ensure negative and positive differences have
symmetric display colors
---
tests/run_tests.R | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 0d21b80..6645064 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -104,6 +104,9 @@ test("scale handling preserves source matrices", {
all(combined$values >= 0 & combined$values <= 1))
constant <- matrix(4, 2, 2)
stopifnot(all(ma_scale(constant) == 0.5))
+ centered <- matrix(c(-3, -1, 0, 3), nrow = 2)
+ stopifnot(isTRUE(all.equal(as.vector(ma_scale(centered,
+ center_zero = TRUE)), c(0, 1/3, 0.5, 1))))
expect_error(ma_scale(matrix(c(0, 1, 2, 3), 2), TRUE), "positive")
})
test("cluster ordering and adjusted Rand index are reproducible", {
From 1ca9814a4b7e97d8c9d8830aa283924e8981a9d5 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:30:11 +0200
Subject: [PATCH 030/134] fix: anchor Plotly cell annotations to axes and
escape uploaded sample labels
---
R/matrix_plot.R | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 20e563e..51c25b3 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -74,11 +74,12 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
z <- ma_scale(m, log_scale, center_zero)
}
ids <- rownames(original)
+ ids_safe <- htmltools::htmlEscape(ids)
hover <- matrix("", nrow = n, ncol = n)
for (j in seq_len(n)) {
dataset <- if (combined) ifelse(seq_len(n) < j, first_name,
ifelse(seq_len(n) > j, second_name, "Diagonal")) else name
- hover[, j] <- paste0("Row: ", ids, "
Column: ", ids[j],
+ hover[, j] <- paste0("Row: ", ids_safe, "
Column: ", ids_safe[j],
"
", dataset, ": ", format(original[, j],
digits = 8L, trim = TRUE))
}
@@ -98,7 +99,7 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
# Text labels only for small matrices; large labels overload the browser.
for (i in seq_len(n)) {
p <- plotly::add_annotations(p, x = ids, y = ids[i],
- text = note[i, ], showarrow = FALSE,
+ text = note[i, ], xref = "x", yref = "y", showarrow = FALSE,
font = list(size = if (n > 35L) 7L else 10L))
}
}
From e679294cc0f25e918a28ac9e7c88e5a335a9fca5 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:30:37 +0200
Subject: [PATCH 031/134] test: compare both shipped XLSX matrices and preserve
historical split-matrix values
---
tests/run_tests.R | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 6645064..1e3308b 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -176,4 +176,23 @@ test("large matrix preview is bounded and retains exact sampled values", {
stopifnot(file.exists(png_file), file.info(png_file)$size > 0)
})
+
+test("bundled XLSX example imports and preserves matrix pairing", {
+ file <- "www/moc_data.xlsx"
+ stopifnot(file.exists(file))
+ sheets <- openxlsx::getSheetNames(file)
+ stopifnot(length(sheets) >= 2L)
+ wb <- openxlsx::loadWorkbook(file)
+ a <- read_matrix_input(file, sheet = sheets[1L], workbook = wb)
+ b <- read_matrix_input(file, sheet = sheets[2L], workbook = wb)
+ matched <- match_matrices(a, b)
+ mixed <- ma_combined(matched$first, matched$second)
+ stopifnot(nrow(a) > 1L, nrow(b) > 1L,
+ identical(rownames(mixed$original), matched$ids),
+ isTRUE(all.equal(mixed$original[upper.tri(mixed$original)],
+ matched$first[upper.tri(matched$first)])),
+ isTRUE(all.equal(mixed$original[lower.tri(mixed$original)],
+ matched$second[lower.tri(matched$second)])))
+})
+
cat("All MAniR regression tests passed.\n")
From 0390341eb6ef3f81ba48a8911378d97c191c06c9 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:31:00 +0200
Subject: [PATCH 032/134] feat: add publication-ready vector PDF/SVG export for
moderate matrix sizes
---
R/matrix_plot.R | 53 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 53 insertions(+)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 51c25b3..20185ac 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -178,3 +178,56 @@ ma_save_png <- function(m, file, palette = "RdBu", title = "",
normalized = normalized)
invisible(file)
}
+
+
+# Vector cells are useful for journal figures at modest dimensions. Rendering
+# millions of individual rectangles into PDF/SVG would be excessive, so larger
+# figures embed the same documented representative raster overview instead.
+ma_publication_plot <- function(m, palette = "RdBu", title = "",
+ normalized = FALSE, vector_limit = 150L) {
+ n <- nrow(m)
+ if (n > vector_limit) {
+ ma_raster(m, palette = palette, title = title, max_side = 1200L,
+ normalized = normalized)
+ return(invisible(FALSE))
+ }
+ z <- if (normalized) m else ma_scale(m)
+ cols <- ma_colors(palette)
+ scaled <- as.integer(1 + round(z * (length(cols) - 1L)))
+ fill <- matrix("#E5E5E5", nrow(z), ncol(z))
+ good <- is.finite(z)
+ fill[good] <- cols[scaled[good]]
+ i <- rep.int(seq_len(n), times = n)
+ j <- rep.int(seq_len(n), each = n)
+ graphics::plot(NA_real_, xlim = c(0.5, n + 0.5),
+ ylim = c(0.5, n + 0.5), xaxs = "i", yaxs = "i",
+ asp = 1, axes = FALSE, xlab = "", ylab = "", main = title)
+ # One vector rectangle per cell, with original numeric values retained
+ # separately in CSV/RDS rather than embedded into rendered colors.
+ graphics::rect(j - 0.5, n - i + 0.5, j + 0.5, n - i + 1.5,
+ col = fill[cbind(i, j)], border = NA)
+ at <- unique(as.integer(round(seq.int(1L, n, length.out = min(20L, n)))))
+ graphics::axis(1, at = at, labels = colnames(m)[at], las = 2, cex.axis = 0.6)
+ graphics::axis(2, at = n + 1L - at, labels = rownames(m)[at],
+ las = 2, cex.axis = 0.6)
+ graphics::box()
+ invisible(TRUE)
+}
+
+ma_save_pdf <- function(m, file, palette = "RdBu", title = "",
+ normalized = FALSE) {
+ grDevices::pdf(file, width = 11, height = 11, useDingbats = FALSE)
+ on.exit(grDevices::dev.off(), add = TRUE)
+ graphics::par(mar = c(10, 10, 5, 2))
+ ma_publication_plot(m, palette, title, normalized)
+ invisible(file)
+}
+
+ma_save_svg <- function(m, file, palette = "RdBu", title = "",
+ normalized = FALSE) {
+ grDevices::svg(file, width = 11, height = 11)
+ on.exit(grDevices::dev.off(), add = TRUE)
+ graphics::par(mar = c(10, 10, 5, 2))
+ ma_publication_plot(m, palette, title, normalized)
+ invisible(file)
+}
From 26949bb29630acbfe8f2ffc659a783e5f053b114 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:31:35 +0200
Subject: [PATCH 033/134] feat: add compact interactive metadata tracks and
consistent export scaling
---
R/matrix_plot.R | 48 +++++++++++++++++++++++++++++++++++++-----------
1 file changed, 37 insertions(+), 11 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 20185ac..6dd4db3 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -60,7 +60,8 @@ ma_combined <- function(first, second, log_scale = FALSE) {
ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
show_numbers = FALSE, combined = FALSE,
first_name = "Matrix 1", second_name = "Matrix 2",
- log_scale = FALSE, center_zero = FALSE) {
+ log_scale = FALSE, center_zero = FALSE,
+ metadata = NULL, group_column = NULL) {
if (!requireNamespace("plotly", quietly = TRUE))
stop("Install the plotly package for interactive plots.")
n <- if (combined) nrow(m$first) else nrow(m)
@@ -83,6 +84,28 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
"
", dataset, ": ", format(original[, j],
digits = 8L, trim = TRUE))
}
+ shapes <- NULL
+ if (!is.null(metadata) && !is.null(group_column) &&
+ isTRUE(nzchar(group_column)) && group_column %in% names(metadata)) {
+ groups <- as.character(metadata[match(ids, rownames(metadata)), group_column])
+ group_text <- htmltools::htmlEscape(ifelse(is.na(groups), "Missing", groups))
+ for (j in seq_len(n))
+ hover[, j] <- paste0(hover[, j], "
",
+ htmltools::htmlEscape(group_column), ": ",
+ group_text)
+ classes <- unique(groups[!is.na(groups)])
+ if (length(classes) <= 30L && length(classes) > 0L) {
+ category_colors <- grDevices::hcl.colors(length(classes), "Set 2")
+ selected <- category_colors[match(groups, classes)]
+ selected[is.na(selected)] <- "#E5E5E5"
+ shapes <- lapply(seq_len(n), function(i) {
+ list(type = "rect", xref = "paper", yref = "y",
+ x0 = -0.035, x1 = -0.012,
+ y0 = n - i - 0.45, y1 = n - i + 0.45,
+ fillcolor = selected[i], line = list(width = 0))
+ })
+ }
+ }
note <- NULL
if (show_numbers && n <= 70L) {
note <- matrix(format(round(original, 2L), trim = TRUE),
@@ -107,7 +130,8 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
xaxis = list(title = "", side = "bottom", tickangle = -65,
automargin = TRUE),
yaxis = list(title = "", automargin = TRUE),
- margin = list(l = 110, b = 130, r = 30, t = 55))
+ shapes = shapes,
+ margin = list(l = 130, b = 130, r = 30, t = 55))
}
# Representative-pixel preview: never use the reduced view for numerical
@@ -170,12 +194,13 @@ ma_raster <- function(m, palette = "RdBu", title = "",
}
ma_save_png <- function(m, file, palette = "RdBu", title = "",
- max_side = 1200L, normalized = FALSE) {
+ max_side = 1200L, normalized = FALSE,
+ log_scale = FALSE) {
grDevices::png(file, width = 1700L, height = 1600L, res = 160L)
on.exit(grDevices::dev.off(), add = TRUE)
graphics::par(mar = c(9, 9, 5, 2))
ma_raster(m, palette = palette, title = title, max_side = max_side,
- normalized = normalized)
+ normalized = normalized, log_scale = log_scale)
invisible(file)
}
@@ -184,14 +209,15 @@ ma_save_png <- function(m, file, palette = "RdBu", title = "",
# millions of individual rectangles into PDF/SVG would be excessive, so larger
# figures embed the same documented representative raster overview instead.
ma_publication_plot <- function(m, palette = "RdBu", title = "",
- normalized = FALSE, vector_limit = 150L) {
+ normalized = FALSE, vector_limit = 150L,
+ log_scale = FALSE) {
n <- nrow(m)
if (n > vector_limit) {
ma_raster(m, palette = palette, title = title, max_side = 1200L,
- normalized = normalized)
+ normalized = normalized, log_scale = log_scale)
return(invisible(FALSE))
}
- z <- if (normalized) m else ma_scale(m)
+ z <- if (normalized) m else ma_scale(m, log_scale = log_scale)
cols <- ma_colors(palette)
scaled <- as.integer(1 + round(z * (length(cols) - 1L)))
fill <- matrix("#E5E5E5", nrow(z), ncol(z))
@@ -215,19 +241,19 @@ ma_publication_plot <- function(m, palette = "RdBu", title = "",
}
ma_save_pdf <- function(m, file, palette = "RdBu", title = "",
- normalized = FALSE) {
+ normalized = FALSE, log_scale = FALSE) {
grDevices::pdf(file, width = 11, height = 11, useDingbats = FALSE)
on.exit(grDevices::dev.off(), add = TRUE)
graphics::par(mar = c(10, 10, 5, 2))
- ma_publication_plot(m, palette, title, normalized)
+ ma_publication_plot(m, palette, title, normalized, log_scale = log_scale)
invisible(file)
}
ma_save_svg <- function(m, file, palette = "RdBu", title = "",
- normalized = FALSE) {
+ normalized = FALSE, log_scale = FALSE) {
grDevices::svg(file, width = 11, height = 11)
on.exit(grDevices::dev.off(), add = TRUE)
graphics::par(mar = c(10, 10, 5, 2))
- ma_publication_plot(m, palette, title, normalized)
+ ma_publication_plot(m, palette, title, normalized, log_scale = log_scale)
invisible(file)
}
From 71f2889263ddf3b901fb6e232068a3c34ce3c044 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:31:49 +0200
Subject: [PATCH 034/134] feat: add HTML/PDF/SVG and exact difference exports
with metadata-aware previews
---
server.R | 38 ++++++++++++++++++++++++++++++++++++--
1 file changed, 36 insertions(+), 2 deletions(-)
diff --git a/server.R b/server.R
index ba3d08e..bdade0e 100644
--- a/server.R
+++ b/server.R
@@ -246,11 +246,15 @@ server <- function(input, output, session) {
size <- if (combined || is_difference) nrow(m$first) else nrow(m)
shiny::req(size <= 300L)
if (is_difference) m <- m$first - m$second
+ d <- data()
+ meta <- if (is_difference || !isTRUE(nzchar(input$metadata_column)))
+ NULL else d$metadata
ma_interactive(m, name = name, palette = input$palette,
show_numbers = input$show_numbers, combined = combined,
first_name = first_label, second_name = second_label,
log_scale = input$log_scale && !is_difference,
- center_zero = is_difference)
+ center_zero = is_difference,
+ metadata = meta, group_column = input$metadata_column)
})
output[[paste0(prefix, "_raster")]] <- shiny::renderPlot({
if (is_difference) shiny::req(input$comparable_scales)
@@ -418,7 +422,7 @@ server <- function(input, output, session) {
filename = function() "MAniR_matrix1.png",
content = function(file)
ma_save_png(ordered_first(), file, palette = input$palette,
- title = "Matrix 1"))
+ title = "Matrix 1", log_scale = input$log_scale))
output$download_combined_png <- shiny::downloadHandler(
filename = function() "MAniR_combined.png",
content = function(file) {
@@ -433,6 +437,36 @@ server <- function(input, output, session) {
title = sprintf("Combined matrix, %d of %d isolates", length(ix),
nrow(m$first)), normalized = TRUE)
})
+ output$download_pdf <- shiny::downloadHandler(
+ filename = function() "MAniR_matrix1.pdf",
+ content = function(file)
+ ma_save_pdf(ordered_first(), file, palette = input$palette,
+ title = "Matrix 1", log_scale = input$log_scale))
+ output$download_svg <- shiny::downloadHandler(
+ filename = function() "MAniR_matrix1.svg",
+ content = function(file)
+ ma_save_svg(ordered_first(), file, palette = input$palette,
+ title = "Matrix 1", log_scale = input$log_scale))
+ output$download_html <- shiny::downloadHandler(
+ filename = function() "MAniR_interactive_preview.html",
+ content = function(file) {
+ m <- ordered_first()
+ ix <- ma_preview(m, max_side = 300L)$indices
+ widget <- ma_interactive(m[ix, ix, drop = FALSE],
+ name = sprintf("Matrix 1 (%d of %d isolates)", length(ix), nrow(m)),
+ palette = input$palette, show_numbers = input$show_numbers,
+ log_scale = input$log_scale)
+ htmlwidgets::saveWidget(widget, file = file, selfcontained = TRUE)
+ })
+ output$download_difference <- shiny::downloadHandler(
+ filename = function() "MAniR_difference_matrix.csv",
+ content = function(file) {
+ shiny::req(input$comparable_scales)
+ m <- combined_first()
+ # Full difference matrices are intentionally materialized only when
+ # explicitly requested for export.
+ write_matrix_csv(m$first - m$second, file)
+ })
output$download_settings <- shiny::downloadHandler(
filename = function() "MAniR_analysis_settings.txt",
content = function(file) {
From b4156314ecda5c467b4928fae705748870fbacc1 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:31:51 +0200
Subject: [PATCH 035/134] feat: expose standalone HTML, PDF, SVG and difference
CSV exports
---
ui.R | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/ui.R b/ui.R
index 3754636..c840235 100644
--- a/ui.R
+++ b/ui.R
@@ -112,7 +112,11 @@ ui <- shiny::fluidPage(
shiny::downloadButton("download_second", "Matrix 2 CSV"),
shiny::downloadButton("download_rds", "Analysis matrices (RDS)"),
shiny::downloadButton("download_first_png", "Matrix 1 PNG"),
+ shiny::downloadButton("download_pdf", "Matrix 1 PDF"),
+ shiny::downloadButton("download_svg", "Matrix 1 SVG"),
+ shiny::downloadButton("download_html", "Interactive HTML preview"),
shiny::downloadButton("download_combined_png", "Combined PNG"),
+ shiny::downloadButton("download_difference", "Exact difference matrix CSV"),
shiny::downloadButton("download_settings", "Analysis settings (text)")
)
)
From ce4e50e0b9faa96d396ea50c0981132beb8f64bf Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:31:55 +0200
Subject: [PATCH 036/134] test: validate publication PDF generation
---
tests/run_tests.R | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 1e3308b..96c9c8e 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -174,6 +174,9 @@ test("large matrix preview is bounded and retains exact sampled values", {
png_file <- tempfile(fileext = ".png")
ma_save_png(big, png_file, max_side = 128)
stopifnot(file.exists(png_file), file.info(png_file)$size > 0)
+ pdf_file <- tempfile(fileext = ".pdf")
+ ma_save_pdf(big[seq_len(20), seq_len(20)], pdf_file)
+ stopifnot(file.exists(pdf_file), file.info(pdf_file)$size > 0)
})
From b72053470cd9144c91c968dcb6bf577e4f76ed3f Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:32:44 +0200
Subject: [PATCH 037/134] fix: match historical complete-linkage clustering by
default
---
R/matrix_analysis.R | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/R/matrix_analysis.R b/R/matrix_analysis.R
index 1d9d68c..1f70afd 100644
--- a/R/matrix_analysis.R
+++ b/R/matrix_analysis.R
@@ -1,7 +1,7 @@
# Reusable matrix calculations; no Shiny dependencies.
matrix_order <- function(m, kind = c("auto", "similarity", "correlation", "distance"),
- cluster = TRUE, max_cluster = 2000L, linkage = "average") {
+ cluster = TRUE, max_cluster = 2000L, linkage = "complete") {
kind <- match.arg(kind)
ids <- colnames(m)
if (!cluster || nrow(m) > max_cluster)
@@ -155,11 +155,13 @@ adjusted_wallace <- function(labels_a, labels_b) {
cluster_concordance <- function(a, b, kind_a = "similarity",
kind_b = "similarity", k = 3L,
- max_n = 2000L) {
+ max_n = 2000L, linkage = "complete") {
if (!identical(rownames(a), rownames(b))) stop("Align matrices first.")
if (k < 2L || k >= nrow(a)) stop("Choose between 2 and n-1 clusters.")
- ca <- matrix_order(a, kind = kind_a, max_cluster = max_n)
- cb <- matrix_order(b, kind = kind_b, max_cluster = max_n)
+ ca <- matrix_order(a, kind = kind_a, max_cluster = max_n,
+ linkage = linkage)
+ cb <- matrix_order(b, kind = kind_b, max_cluster = max_n,
+ linkage = linkage)
if (is.null(ca$tree) || is.null(cb$tree))
stop("Cluster concordance exceeds the configured clustering limit.")
first <- stats::cutree(ca$tree, k = k)
From cbba48b240ae6c004d5c91bfaf08a361b493a53e Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:32:46 +0200
Subject: [PATCH 038/134] feat: persist selected clustering linkage throughout
analysis and exports
---
server.R | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/server.R b/server.R
index bdade0e..a7c24cf 100644
--- a/server.R
+++ b/server.R
@@ -94,7 +94,8 @@ server <- function(input, output, session) {
if (!is.null(explicit))
return(c(intersect(explicit, colnames(m)),
setdiff(colnames(m), explicit)))
- matrix_order(m, kind, cluster = isTRUE(input$cluster))$ids
+ matrix_order(m, kind, cluster = isTRUE(input$cluster),
+ linkage = input$linkage)$ids
}
order1 <- order_for(a, input$kind1)
shiny::incProgress(0.30)
@@ -114,7 +115,8 @@ server <- function(input, output, session) {
clustering_skipped = isTRUE(input$cluster) &&
is.null(explicit) &&
(nrow(a) > 2000L || (!is.null(b) && nrow(b) > 2000L)),
- kind1 = input$kind1, kind2 = input$kind2)
+ kind1 = input$kind1, kind2 = input$kind2,
+ linkage = input$linkage)
}, error = function(e) {
shiny::showNotification(conditionMessage(e), type = "error",
duration = NULL)
@@ -353,7 +355,8 @@ server <- function(input, output, session) {
shiny::validate(shiny::need(input$cluster_k < nrow(m$first),
"Choose fewer clusters than shared isolates."))
cluster_concordance(m$first, m$second, kind_a = data()$kind1,
- kind_b = data()$kind2, k = input$cluster_k)
+ kind_b = data()$kind2, k = input$cluster_k,
+ linkage = data()$linkage)
})
output$cluster_summary <- shiny::renderTable({
c <- concordance()
@@ -479,6 +482,7 @@ server <- function(input, output, session) {
paste("Sample matching:", d$matching),
paste("Cluster requested:", input$cluster),
paste("Cluster limit:", 2000L),
+ paste("Linkage:", d$linkage),
paste("Color palette:", input$palette),
paste("Log display scaling:", input$log_scale),
paste("Comparable units:", input$comparable_scales),
From ca888f4e2faff9819d64a5ce0539a0c9f12d169f Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:32:49 +0200
Subject: [PATCH 039/134] feat: expose complete, average, single and Ward
cluster linkage
---
ui.R | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/ui.R b/ui.R
index c840235..b4719c9 100644
--- a/ui.R
+++ b/ui.R
@@ -45,6 +45,10 @@ ui <- shiny::fluidPage(
shiny::hr(),
shiny::h4("Visualization"),
shiny::checkboxInput("cluster", "Cluster samples", value = TRUE),
+ shiny::selectInput("linkage", "Clustering linkage",
+ choices = c("Complete (historical default)" = "complete",
+ "Average" = "average", "Single" = "single",
+ "Ward D2" = "ward.D2"), selected = "complete"),
shiny::selectInput("palette", "Palette",
choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
"Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
From a8939c8138a9d121df3dad2678ba285e68a968af Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:32:51 +0200
Subject: [PATCH 040/134] docs: explain historical clustering default and
configurable linkage
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 344b946..4c8bbe2 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ Enable "Zoom into a region" and enter the first isolate index and the region siz
The combined views use the first matrix above the diagonal and the second below the diagonal. One view uses the sample ordering from matrix 1; the other uses the ordering from matrix 2. Because ANI percentages and MALDI similarities can have different units, **each triangle is display-scaled independently**. Hover and click inspections, pairwise exports and RDS downloads retain the original numeric values. Log display scaling requires positive finite values.
-Clustering defaults to average-linkage hierarchical clustering. Large matrices above 2,000 isolates skip clustering by default to avoid excessive computation and memory use. Supply a precomputed ordering if desired. The original numerical matrices are not altered by clustering.
+Clustering defaults to complete-linkage hierarchical clustering, matching the original corrplot linkage default. Large matrices above 2,000 isolates skip clustering by default to avoid excessive computation and memory use. Supply a precomputed ordering if desired. The original numerical matrices are not altered by clustering.
## Research features
From c3e9936f4895ae3a233a75d2bdb81f3874c01920 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:33:27 +0200
Subject: [PATCH 041/134] fix: preserve sample IDs with leading zeroes in
delimited imports
---
R/matrix_io.R | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/R/matrix_io.R b/R/matrix_io.R
index 2302537..7bd1dd0 100644
--- a/R/matrix_io.R
+++ b/R/matrix_io.R
@@ -20,15 +20,24 @@ read_matrix_input <- function(path, sheet = NULL, workbook = NULL, format = path
sep <- if (ext == "tsv" || grepl("\\.tsv\\.gz$", format, ignore.case = TRUE)) "\t" else ","
if (ext != "gz" && requireNamespace("data.table", quietly = TRUE)) {
x <- data.table::fread(path, sep = sep, data.table = FALSE,
- check.names = FALSE, showProgress = FALSE)
+ check.names = FALSE, showProgress = FALSE,
+ colClasses = list(character = 1L))
if (ncol(x) < 2L) stop("The file needs an ID column and matrix columns.")
ids <- x[[1L]]
x <- x[-1L]
rownames(x) <- as.character(ids)
} else {
+ # Detect header width without parsing all of the large matrix as text.
+ # Explicitly read the first ID column as character to retain "0012".
+ header_con <- if (ext == "gz") gzfile(path, "rt") else file(path, "rt")
+ columns <- tryCatch(
+ scan(header_con, what = character(), sep = sep, quote = '"',
+ nlines = 1L, quiet = TRUE),
+ finally = close(header_con))
+ classes <- c("character", rep(NA_character_, max(0L, length(columns) - 1L)))
x <- utils::read.table(path, sep = sep, header = TRUE, row.names = 1L,
check.names = FALSE, comment.char = "",
- stringsAsFactors = FALSE)
+ stringsAsFactors = FALSE, colClasses = classes)
}
} else {
stop("Unsupported matrix format. Use XLSX, CSV, TSV, CSV.GZ, TSV.GZ or RDS.")
From ae492dfcbab81620d6bbe581a37213171fa40785 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:33:29 +0200
Subject: [PATCH 042/134] test: guard numeric-looking sample IDs against lossy
CSV parsing
---
tests/run_tests.R | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 96c9c8e..1042ce8 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -198,4 +198,15 @@ test("bundled XLSX example imports and preserves matrix pairing", {
matched$second[lower.tri(matched$second)])))
})
+
+test("CSV import preserves leading zeroes in sample IDs", {
+ id <- c("001", "002", "003")
+ x <- matrix(c(1, .8, .5, .8, 1, .6, .5, .6, 1),
+ nrow = 3L, dimnames = list(id, id))
+ file <- tempfile(fileext = ".csv")
+ write_matrix_csv(x, file)
+ y <- read_matrix_input(file)
+ stopifnot(identical(rownames(y), id), identical(colnames(y), id))
+})
+
cat("All MAniR regression tests passed.\n")
From d7054143cd38c0360674fae09cd2cae4974df8ec Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:33:55 +0200
Subject: [PATCH 043/134] fix: support zero-diagonal distance matrices in log1p
visualization
---
R/matrix_plot.R | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 6dd4db3..02977d2 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -20,8 +20,8 @@ ma_scale <- function(x, log_scale = FALSE, center_zero = FALSE) {
valid <- is.finite(x)
if (!any(valid)) stop("There are no finite values to display.")
if (log_scale) {
- if (any(x[valid] <= 0)) stop("Log scaling requires strictly positive values.")
- x[valid] <- log(x[valid])
+ if (any(x[valid] < 0)) stop("Log scaling requires nonnegative values.")
+ x[valid] <- log1p(x[valid])
}
if (center_zero) {
if (log_scale) stop("Zero-centered scaling cannot use logarithms.")
From 175fd07c8d12770baec918eaeaded7d47e5477ff Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:33:57 +0200
Subject: [PATCH 044/134] test: verify zero-safe logarithmic scaling rejects
negative values
---
tests/run_tests.R | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 1042ce8..e6f0e18 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -107,7 +107,8 @@ test("scale handling preserves source matrices", {
centered <- matrix(c(-3, -1, 0, 3), nrow = 2)
stopifnot(isTRUE(all.equal(as.vector(ma_scale(centered,
center_zero = TRUE)), c(0, 1/3, 0.5, 1))))
- expect_error(ma_scale(matrix(c(0, 1, 2, 3), 2), TRUE), "positive")
+ stopifnot(all(is.finite(ma_scale(matrix(c(0, 1, 2, 3), 2), TRUE))))
+ expect_error(ma_scale(matrix(c(-1, 0, 1, 2), 2), TRUE), "nonnegative")
})
test("cluster ordering and adjusted Rand index are reproducible", {
c1 <- matrix_order(m)
From 60432b516988addbaef854717dff5db65b1a800f Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:34:00 +0200
Subject: [PATCH 045/134] docs: specify log1p behavior for zero-containing
distance matrices
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 4c8bbe2..0fb1cec 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Smaller matrices (300 isolates or fewer) are displayed as interactive Plotly hea
Enable "Zoom into a region" and enter the first isolate index and the region size to inspect adjacent samples. When a region exceeds 512 isolates, it is itself reduced to a representative 512-by-512 display; choose a smaller region to inspect every cell.
-The combined views use the first matrix above the diagonal and the second below the diagonal. One view uses the sample ordering from matrix 1; the other uses the ordering from matrix 2. Because ANI percentages and MALDI similarities can have different units, **each triangle is display-scaled independently**. Hover and click inspections, pairwise exports and RDS downloads retain the original numeric values. Log display scaling requires positive finite values.
+The combined views use the first matrix above the diagonal and the second below the diagonal. One view uses the sample ordering from matrix 1; the other uses the ordering from matrix 2. Because ANI percentages and MALDI similarities can have different units, **each triangle is display-scaled independently**. Hover and click inspections, pairwise exports and RDS downloads retain the original numeric values. Log display scaling uses log(1+x) on nonnegative values (including a zero diagonal); negative correlations cannot be logarithmically displayed.
Clustering defaults to complete-linkage hierarchical clustering, matching the original corrplot linkage default. Large matrices above 2,000 isolates skip clustering by default to avoid excessive computation and memory use. Supply a precomputed ordering if desired. The original numerical matrices are not altered by clustering.
From cb0a672ed2705d70a6d7650e0c2f72fbc6403e34 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:34:21 +0200
Subject: [PATCH 046/134] bench: add reproducible original-versus-upgrade
plotting comparison
---
benchmarks/compare_original.R | 85 +++++++++++++++++++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 benchmarks/compare_original.R
diff --git a/benchmarks/compare_original.R b/benchmarks/compare_original.R
new file mode 100644
index 0000000..e6436c3
--- /dev/null
+++ b/benchmarks/compare_original.R
@@ -0,0 +1,85 @@
+#!/usr/bin/env Rscript
+# Compare the historical corrplot + heatmaply pipeline with the upgraded
+# cluster-once + adaptive-plot pipeline. This replays representative calls,
+# rather than executing the original interactive app in a browser.
+# Prerequisites: install.packages(c("corrplot", "heatmaply", "plotly"))
+# Usage: Rscript benchmarks/compare_original.R
+# Rscript benchmarks/compare_original.R --smoke
+
+source("R/matrix_io.R")
+source("R/matrix_analysis.R")
+source("R/matrix_plot.R")
+for (pkg in c("corrplot", "heatmaply", "plotly")) {
+ if (!requireNamespace(pkg, quietly = TRUE))
+ stop("Install ", pkg, " to compare with the historical rendering pipeline.")
+}
+smoke <- "--smoke" %in% commandArgs(trailingOnly = TRUE)
+sizes <- if (smoke) c(50L, 100L) else c(100L, 300L, 600L)
+repeats <- if (smoke) 1L else 3L
+results <- list()
+index <- 1L
+dir.create("benchmarks/results", recursive = TRUE, showWarnings = FALSE)
+
+record <- function(n, repeat_index, pipeline, expression) {
+ gc(reset = TRUE)
+ start <- proc.time()[["elapsed"]]
+ invisible(force(expression))
+ elapsed <- proc.time()[["elapsed"]] - start
+ results[[index]] <<- data.frame(
+ n = n, repeat_index = repeat_index, pipeline = pipeline,
+ seconds = elapsed,
+ approx_vector_heap_mb = unname(gc()[2L, "max used"]) * 8 / 1024^2)
+ index <<- index + 1L
+}
+
+for (n in sizes) {
+ set.seed(2026)
+ features <- matrix(stats::rnorm(n * 5L), ncol = 5L)
+ m <- exp(-as.matrix(stats::dist(features)) / 3)
+ ids <- sprintf("isolate_%05d", seq_len(n))
+ dimnames(m) <- list(ids, ids)
+ diag(m) <- 1
+ rm(features)
+ for (repeat_index in seq_len(repeats)) {
+ record(n, repeat_index, "original_corrplot_then_heatmaply", {
+ # Original MAniR built a corrplot to obtain cluster order and also
+ # constructed a heatmaply figure. Legacy plots are run on an offscreen
+ # graphics device so the script works headlessly.
+ pdf_file <- tempfile(fileext = ".pdf")
+ grDevices::pdf(pdf_file)
+ tryCatch({
+ corrplot::corrplot.mixed(m, lower = "color", upper = "number",
+ is.corr = FALSE, order = "hclust",
+ diag = "n")
+ }, finally = {
+ grDevices::dev.off()
+ unlink(pdf_file)
+ })
+ fig <- heatmaply::heatmaply(m, show_dendrogram = c(FALSE, FALSE),
+ cellnote = round(m, 2), hide_colorbar = TRUE)
+ plotly::plotly_build(fig)
+ })
+ record(n, repeat_index, "upgraded_cluster_then_adaptive_render", {
+ ordering <- matrix_order(m, kind = "similarity")
+ ordered <- m[ordering$ids, ordering$ids, drop = FALSE]
+ if (n <= 300L) {
+ plotly::plotly_build(ma_interactive(ordered, name = "Matrix"))
+ } else {
+ png_file <- tempfile(fileext = ".png")
+ ma_save_png(ordered, png_file, max_side = 512L)
+ unlink(png_file)
+ }
+ })
+ }
+ rm(m)
+ gc()
+}
+result <- do.call(rbind, results)
+print(result, row.names = FALSE)
+output <- file.path("benchmarks/results",
+ paste0("comparison-", format(Sys.time(), "%Y%m%d-%H%M%S"), ".csv"))
+utils::write.csv(result, output, row.names = FALSE)
+cat("Benchmark output:", output, "\n")
+cat("The renderers have different capabilities above 300 isolates; compare\n",
+ "end-user latency, not pixel-for-pixel rendering speed, in that range.\n")
+print(utils::sessionInfo())
From a4ee6900803abe97b9f71b3c67ff7be1777513ef Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:34:43 +0200
Subject: [PATCH 047/134] feat: make large-data upload size configurable
---
app.R | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app.R b/app.R
index 8fe5628..be0d4a2 100644
--- a/app.R
+++ b/app.R
@@ -5,6 +5,10 @@ if (length(missing))
stop("Install required packages before launching: ",
paste(missing, collapse = ", "),
". See README.md for installation instructions.")
+max_upload_mb <- as.numeric(Sys.getenv("MANIR_MAX_UPLOAD_MB", "1024"))
+if (!is.finite(max_upload_mb) || max_upload_mb < 1)
+ stop("MANIR_MAX_UPLOAD_MB must be a positive number.")
+options(shiny.maxRequestSize = max_upload_mb * 1024^2)
source("R/matrix_io.R", local = TRUE)
source("R/matrix_analysis.R", local = TRUE)
source("R/matrix_plot.R", local = TRUE)
From 6119b90771401b56330fe63c52315ce0e12691d6 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:34:45 +0200
Subject: [PATCH 048/134] docs: guide large dataset uploads toward CSV and RDS
---
ui.R | 3 +++
1 file changed, 3 insertions(+)
diff --git a/ui.R b/ui.R
index b4719c9..88fc355 100644
--- a/ui.R
+++ b/ui.R
@@ -19,6 +19,9 @@ ui <- shiny::fluidPage(
shiny::sidebarLayout(
shiny::sidebarPanel(width = 3,
shiny::h4("Input data"),
+ shiny::p(class = "small-help",
+ "For very large matrices, CSV or RDS is preferable to Excel.
+ Import uses RAM for the matrix and temporary validation data."),
shiny::fileInput("first_file", "First matrix",
accept = c(".xlsx", ".xlsm", ".csv", ".tsv", ".txt",
".gz", ".rds")),
From abe374aca09fee322be7d2d4ad3f15cb0f99b8af Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:34:48 +0200
Subject: [PATCH 049/134] docs: cover configurable upload limits and new export
and baseline tools
---
README.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 0fb1cec..6db4f41 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ This development branch is **MAniR 3.0 prerelease**. The historical application
## Installation
-Install R 4.2 or later. In the repository directory, run:
+Install R 4.2 or later. The upload limit defaults to 1,024 MB and can be configured with the `MANIR_MAX_UPLOAD_MB` environment variable. Plan available RAM for at least the uploaded matrices plus working data, especially for two 10,000-isolate matrices. For those larger projects, RDS or CSV is preferable to Excel. In the repository directory, run:
```r
install.packages(c("shiny", "plotly", "openxlsx", "RColorBrewer"))
@@ -17,7 +17,7 @@ install.packages(c("data.table", "fastcluster"))
shiny::runApp(".")
```
-For scripted validation, run `Rscript tests/run_tests.R`. For repeatable performance measurements, run `Rscript benchmarks/benchmark.R` and retain the generated CSV. The optional `--smoke` flag runs a short check; `--large` includes 10,000-isolate data and requires substantial RAM.
+For scripted validation, run `Rscript tests/run_tests.R`. For repeatable performance measurements, run `Rscript benchmarks/benchmark.R` and retain the generated CSV. To compare representative original and new plotting pipelines, install the optional `corrplot` and `heatmaply` packages and run `Rscript benchmarks/compare_original.R`. The optional `--smoke` flag runs a short check; `--large` includes 10,000-isolate data and requires substantial RAM.
## Input formats
@@ -57,7 +57,7 @@ MAniR accepts **precomputed** MALDI-TOF, ANI, dDDH and other pairwise results. R
## Export and reproducibility
-The export tab provides exact input matrices as CSV, an RDS bundle containing both matrices, optional metadata and sample order, static PNGs and a text manifest of analysis settings and R session information. Pairwise comparison and cluster assignments have their own CSV exports. Large-image exports use representative raster views with up to 1,200 rows and columns, not an exact full-resolution image of a 10,000-isolate matrix.
+The export tab provides exact input matrices as CSV, an RDS bundle containing both matrices, optional metadata and sample order, static PNG, PDF and SVG files, a standalone interactive HTML preview and a text manifest of analysis settings and R session information. Pairwise comparison and cluster assignments have their own CSV exports. PDF and SVG contain individual vector cells for matrices with at most 150 isolates and a representative raster for larger inputs. Interactive HTML previews include at most 300 isolates and require Pandoc for self-contained HTML. Large-image exports use representative raster views with up to 1,200 rows and columns, not an exact full-resolution image of a 10,000-isolate matrix. Exact difference matrices can be exported as CSV when both datasets have comparable numerical units.
To use MAniR reproducibly, archive the source matrices, the metadata, the settings manifest, the installed R package versions, the generated plots and the resulting tables. Never infer a biological cutoff from the normalized heatmap colors; inspect the original numeric values and justify cutoffs from the relevant study.
From 02f74d36c24c348f8220aa5bc713556f5a0323ad Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:35:12 +0200
Subject: [PATCH 050/134] perf: avoid multiple dense copies of all-numeric
fread and Excel tables
---
R/matrix_io.R | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/R/matrix_io.R b/R/matrix_io.R
index 7bd1dd0..447b633 100644
--- a/R/matrix_io.R
+++ b/R/matrix_io.R
@@ -68,10 +68,11 @@ validate_matrix <- function(x, kind = c("auto", "similarity", "correlation", "di
paste(head(only_rows, 10L), collapse = ", "),
paste(head(only_cols, 10L), collapse = ", ")))
}
- if (is.matrix(x) && is.numeric(x)) {
- # Fast path: avoid coercing an already numeric N*N matrix into another
- # full-size matrix during import.
- m <- x
+ if ((is.matrix(x) && is.numeric(x)) ||
+ (is.data.frame(x) && all(vapply(x, is.numeric, logical(1L))))) {
+ # Fast path: common fread/openxlsx inputs contain numeric columns.
+ # Materialize a single numeric matrix rather than three dense copies.
+ m <- if (is.matrix(x)) x else as.matrix(x)
dimnames(m) <- list(row_ids, col_ids)
} else {
original <- as.matrix(x)
From 33cd5c79054189fb5eb29e6764578bc456d7342c Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:35:14 +0200
Subject: [PATCH 051/134] test: validate numeric data-frame fast path without
value changes
---
tests/run_tests.R | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index e6f0e18..c3a4994 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -29,6 +29,12 @@ test("matrix import reorders named rows to named columns", {
isTRUE(all.equal(unname(imported), unname(m),
check.attributes = FALSE)))
})
+test("numeric data frames take the exact-value matrix import path", {
+ data <- as.data.frame(m, check.names = FALSE)
+ result <- validate_matrix(data, "similarity")
+ stopifnot(isTRUE(all.equal(unname(result), unname(m),
+ check.attributes = FALSE)))
+})
test("matrix identifiers are trimmed without changing values", {
dirty <- m
rownames(dirty) <- paste0(" ", rownames(dirty), " ")
From 83988d404de6587e498cba66abce44845b60b17d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:35:36 +0200
Subject: [PATCH 052/134] test: add headless Shiny upload and reactive state
regression
---
tests/test_shiny.R | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 tests/test_shiny.R
diff --git a/tests/test_shiny.R b/tests/test_shiny.R
new file mode 100644
index 0000000..46cead2
--- /dev/null
+++ b/tests/test_shiny.R
@@ -0,0 +1,35 @@
+# Minimal server integration test. Requires Shiny and the declared runtime packages.
+source("R/matrix_io.R")
+source("R/matrix_analysis.R")
+source("R/matrix_plot.R")
+source("server.R")
+
+ids <- c("001", "002", "003", "004")
+m <- matrix(c(1, .7, .4, .3,
+ .7, 1, .8, .5,
+ .4, .8, 1, .9,
+ .3, .5, .9, 1), 4, byrow = TRUE,
+ dimnames = list(ids, ids))
+path <- tempfile(fileext = ".csv")
+write_matrix_csv(m, path)
+file_info <- data.frame(name = "example.csv",
+ size = as.numeric(file.info(path)$size),
+ type = "text/csv", datapath = path,
+ stringsAsFactors = FALSE)
+
+shiny::testServer(server, {
+ session$setInputs(
+ first_file = file_info,
+ kind1 = "similarity", kind2 = "similarity",
+ cluster = FALSE, linkage = "complete",
+ match_mode = "strict", visualize = 1
+ )
+ loaded_data <- loaded()
+ stopifnot(!is.null(loaded_data), nrow(loaded_data$first) == 4L,
+ identical(colnames(loaded_data$first), ids),
+ is.null(loaded_data$second))
+ # A palette change should affect only rendering, not the loaded matrix.
+ session$setInputs(palette = "Viridis")
+ stopifnot(identical(loaded()$first, loaded_data$first))
+})
+cat("Shiny upload and reactive smoke test passed.\n")
From b686d3c5036de404c9ca14c43e7a397f40753c4e Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:35:45 +0200
Subject: [PATCH 053/134] ci: exercise uploaded CSV data and Shiny reactive
state on both platforms
---
.github/workflows/r-tests.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/r-tests.yml b/.github/workflows/r-tests.yml
index 9891192..bd3bff1 100644
--- a/.github/workflows/r-tests.yml
+++ b/.github/workflows/r-tests.yml
@@ -26,6 +26,8 @@ jobs:
run: Rscript tests/run_tests.R
- name: Parse and load the Shiny application
run: Rscript -e "stopifnot(inherits(source('app.R')$value, 'shiny.appobj'))"
+ - name: Shiny upload smoke test
+ run: Rscript tests/test_shiny.R
- name: Benchmark smoke
run: Rscript benchmarks/benchmark.R --smoke
- name: Upload benchmark smoke results
@@ -47,3 +49,5 @@ jobs:
run: Rscript tests/run_tests.R
- name: Parse and load application
run: Rscript -e "stopifnot(inherits(source('app.R')$value, 'shiny.appobj'))"
+ - name: Shiny upload smoke test
+ run: Rscript tests/test_shiny.R
From 5c3d95f4f7fedfe0f8c6f4c9f274f9c40e8f497f Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:36:22 +0200
Subject: [PATCH 054/134] docs: record implemented changes and outstanding
release validation
---
docs/UPGRADE_WORKLOG.md | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/docs/UPGRADE_WORKLOG.md b/docs/UPGRADE_WORKLOG.md
index 0e5ac39..0325458 100644
--- a/docs/UPGRADE_WORKLOG.md
+++ b/docs/UPGRADE_WORKLOG.md
@@ -38,3 +38,38 @@ the benchmark script is infrastructure, not evidence of a speedup.
- Tested failure handling for bad/missing IDs, invalid scales and missing data.
- Accurate documentation of large-dataset preview sampling limits.
- Measured, repeated performance comparison before a release speed claim.
+
+
+## Implementation progress (September 28, 2026)
+Completed in the upgrade branch:
+- Reworked upload parsing and numeric validation, including preservation of
+ leading-zero sample identifiers, row/column alignment, strict/intersection
+ matching, missingness checks and blockwise symmetry validation.
+- Split calculation and rendering into reusable R modules; replaced repeated
+ clustering with a single selected sample order. Historical complete linkage
+ is the default; alternative linkage settings are available.
+- Added lazy Plotly views for up to 300 isolates and a region-zoomable,
+ representative raster viewer for larger datasets. Large combined plots
+ slice the viewport before assembling normalized matrices.
+- Added metadata color tracks, pairwise agreement summaries, a two-sided
+ isolate-permutation Mantel test, ARI and adjusted Wallace, and descriptive
+ within-group/between-group comparisons.
+- Added CSV/TSV/gzip/RDS imports, exact original matrix exports, PNG,
+ PDF/SVG and HTML preview exports, settings manifest, tests, Linux/Windows CI,
+ configurable upload size, and staged benchmarking scripts.
+- Opened draft PR #2 to keep the original main branch intact.
+
+Verification still outstanding:
+- No R interpreter is installed in the environment that authored these
+ changes, so the R test and Shiny integration suites cannot be reported
+ as passing. The workflow is committed but no completed GitHub Actions
+ job has been observed yet.
+- Run the bundled XLSX regression, both CI platforms, and at least three
+ repeated old/new benchmark comparisons on specified hardware.
+- Run large real datasets locally, record memory/time and inspect exact
+ values and visual ordering before release.
+- Record dependency versions from the validated environment, and produce
+ a lockfile with renv after a successful R run.
+
+Do not merge or tag this development branch as a validated release until
+these checks pass. The arXiv manuscript is outside this work scope.
From fc761078351c6d259d5ce5f56b635ac50a63211b Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:36:35 +0200
Subject: [PATCH 055/134] chore: ignore generated local benchmark result files
---
.gitignore | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.gitignore b/.gitignore
index 6ce1d85..f60b9b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@ vignettes/*.pdf
# Shinyapps.io information
rsconnect/
.lintr
+
+# Generated benchmark measurements
+benchmarks/results/
From 3410f75c3718faaf1adb28d445c3db711606bcf7 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:36:38 +0200
Subject: [PATCH 056/134] docs: avoid placeholder author contact in prerelease
metadata
---
DESCRIPTION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/DESCRIPTION b/DESCRIPTION
index 02cae0a..626abdf 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -2,7 +2,7 @@ Package: MAniR
Type: Project
Title: Interactive Pairwise Matrix Comparison for MALDI-TOF and Genomic Analysis
Version: 3.0.0
-Authors@R: person("BiKC", role = c("aut", "cre"), email = "opensource@example.invalid")
+Authors@R: person("BiKC", role = "aut")
Description: Shiny application to compare similarity, correlation and distance
matrices using scalable visualization, isolate-level metadata and
pairwise analytical summaries. The application is run directly from app.R.
From 4be46712baa89e380368335620c9f093f98828c8 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:36:49 +0200
Subject: [PATCH 057/134] chore: remove superseded experimental Plotly script
---
www/custom_plotly.js | 21 ---------------------
1 file changed, 21 deletions(-)
delete mode 100644 www/custom_plotly.js
diff --git a/www/custom_plotly.js b/www/custom_plotly.js
deleted file mode 100644
index 0487ba3..0000000
--- a/www/custom_plotly.js
+++ /dev/null
@@ -1,21 +0,0 @@
-const createPlotly = function(message) {
- // message is an array where first element is the data and second is the metadata
- let data = [{
- z: message[0],
- type: 'heatmap',
- colorscale: 'Viridis',
- showscale: true,
- colorbar: {
- title: 'Colorbar Title'
- },
- xgap: 1,
- ygap: 1,
- hovertemplate: 'Value: %{z}
Row: %{y}
Column: %{x}
'
- }];
- Plotly.newPlot('myDiv', data);
-};
-
-// custom message handler from shiny
-Shiny.addCustomMessageHandler('first_correlation_matrix', function(message) {
- createPlotly(message);
-});
\ No newline at end of file
From 4664347ca9cb8b4636648142ba2f294aee3d8f2e Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:37:11 +0200
Subject: [PATCH 058/134] fix: initialize click-triggered loading reliably in
Shiny and integration tests
---
server.R | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server.R b/server.R
index a7c24cf..cc887ad 100644
--- a/server.R
+++ b/server.R
@@ -123,7 +123,7 @@ server <- function(input, output, session) {
NULL
})
})
- }, ignoreInit = TRUE)
+ }, ignoreNULL = TRUE)
data <- shiny::reactive({ shiny::req(loaded()); loaded() })
has_two <- shiny::reactive(!is.null(data()$second))
From f6a4cf2241d944474469a1db90dac1b87a0ba495 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:37:34 +0200
Subject: [PATCH 059/134] build: declare HTML widget and safe-label
dependencies explicitly
---
DESCRIPTION | 2 ++
1 file changed, 2 insertions(+)
diff --git a/DESCRIPTION b/DESCRIPTION
index 626abdf..153d24e 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -13,6 +13,8 @@ Depends:
Imports:
shiny,
plotly,
+ htmltools,
+ htmlwidgets,
openxlsx,
RColorBrewer
Suggests:
From ad15c10877a001cced78a98e87d59194f2ec1db6 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:37:36 +0200
Subject: [PATCH 060/134] build: validate direct interactive export
dependencies at startup
---
app.R | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app.R b/app.R
index be0d4a2..9a96bae 100644
--- a/app.R
+++ b/app.R
@@ -1,5 +1,6 @@
# MAniR Shiny entry point. Dependencies are documented in README and renv.
-required <- c("shiny", "plotly", "openxlsx", "RColorBrewer")
+required <- c("shiny", "plotly", "htmltools", "htmlwidgets",
+ "openxlsx", "RColorBrewer")
missing <- setdiff(required, rownames(installed.packages()))
if (length(missing))
stop("Install required packages before launching: ",
From f35582b59c18b21d697dde7569b9f363a8e65a73 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:37:39 +0200
Subject: [PATCH 061/134] docs: include HTML dependencies in reproducible
installation
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 6db4f41..b368efd 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,8 @@ This development branch is **MAniR 3.0 prerelease**. The historical application
Install R 4.2 or later. The upload limit defaults to 1,024 MB and can be configured with the `MANIR_MAX_UPLOAD_MB` environment variable. Plan available RAM for at least the uploaded matrices plus working data, especially for two 10,000-isolate matrices. For those larger projects, RDS or CSV is preferable to Excel. In the repository directory, run:
```r
-install.packages(c("shiny", "plotly", "openxlsx", "RColorBrewer"))
+install.packages(c("shiny", "plotly", "htmltools", "htmlwidgets",
+ "openxlsx", "RColorBrewer"))
# Optional accelerators (recommended for large datasets):
install.packages(c("data.table", "fastcluster"))
shiny::runApp(".")
From 98bed21486275bffe70ecea39649ed632c64da49 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:45:12 +0200
Subject: [PATCH 062/134] fix: use rep() for repeated PDF/SVG vector column
indices
---
R/matrix_plot.R | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 02977d2..1228170 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -224,7 +224,7 @@ ma_publication_plot <- function(m, palette = "RdBu", title = "",
good <- is.finite(z)
fill[good] <- cols[scaled[good]]
i <- rep.int(seq_len(n), times = n)
- j <- rep.int(seq_len(n), each = n)
+ j <- rep(seq_len(n), each = n)
graphics::plot(NA_real_, xlim = c(0.5, n + 0.5),
ylim = c(0.5, n + 0.5), xaxs = "i", yaxs = "i",
asp = 1, axes = FALSE, xlab = "", ylab = "", main = title)
From 3243e5fc0d79172b2bc6411899ef29990ca18a30 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:46:47 +0200
Subject: [PATCH 063/134] ci: prevent shell expansion from corrupting Shiny
startup assertion
---
.github/workflows/r-tests.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/r-tests.yml b/.github/workflows/r-tests.yml
index bd3bff1..e89abc7 100644
--- a/.github/workflows/r-tests.yml
+++ b/.github/workflows/r-tests.yml
@@ -25,7 +25,7 @@ jobs:
- name: Test numerical correctness, files and plot export
run: Rscript tests/run_tests.R
- name: Parse and load the Shiny application
- run: Rscript -e "stopifnot(inherits(source('app.R')$value, 'shiny.appobj'))"
+ run: Rscript -e "stopifnot(inherits(getElement(source('app.R'), 'value'), 'shiny.appobj'))"
- name: Shiny upload smoke test
run: Rscript tests/test_shiny.R
- name: Benchmark smoke
@@ -48,6 +48,6 @@ jobs:
- name: Regression tests
run: Rscript tests/run_tests.R
- name: Parse and load application
- run: Rscript -e "stopifnot(inherits(source('app.R')$value, 'shiny.appobj'))"
+ run: Rscript -e "stopifnot(inherits(getElement(source('app.R'), 'value'), 'shiny.appobj'))"
- name: Shiny upload smoke test
run: Rscript tests/test_shiny.R
From 693ab49f77746098ace8e0c483696cb007b0563a Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:49:31 +0200
Subject: [PATCH 064/134] fix: correct multiline if/else syntax in benchmark
runner
---
benchmarks/benchmark.R | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/benchmarks/benchmark.R b/benchmarks/benchmark.R
index c550c03..41c9c90 100644
--- a/benchmarks/benchmark.R
+++ b/benchmarks/benchmark.R
@@ -13,9 +13,13 @@ source("R/matrix_plot.R")
args <- commandArgs(trailingOnly = TRUE)
smoke <- "--smoke" %in% args
large <- "--large" %in% args
-sizes <- if (smoke) c(60L, 120L) else if (large)
+sizes <- if (smoke) {
+ c(60L, 120L)
+} else if (large) {
c(500L, 1000L, 2500L, 5000L, 10000L)
-else c(100L, 300L, 600L, 1000L, 2500L)
+} else {
+ c(100L, 300L, 600L, 1000L, 2500L)
+}
repeats <- if (smoke) 1L else 3L
dir.create("benchmarks/results", recursive = TRUE, showWarnings = FALSE)
results <- list()
From 5ba6ea6d36b536d52962ffc551c8c65688dc2daf Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:29:20 +0200
Subject: [PATCH 065/134] feat: add synthetic teaching example
(examples/ani.csv)
---
examples/ani.csv | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 examples/ani.csv
diff --git a/examples/ani.csv b/examples/ani.csv
new file mode 100644
index 0000000..0184c0e
--- /dev/null
+++ b/examples/ani.csv
@@ -0,0 +1,13 @@
+sample_id,ISO_01,ISO_02,ISO_03,ISO_04,ISO_05,ISO_06,ISO_07,ISO_08,ISO_09,ISO_10,ISO_11,ISO_12
+ISO_01,100,99.885,98.85,98.965,95.69,95.52,95.35,97.22,97.05,96.88,96.71,96.54
+ISO_02,99.885,100,99.655,99.77,96.88,96.71,96.54,96.37,96.2,96.03,95.86,95.69
+ISO_03,98.85,99.655,100,99.425,96.03,95.86,95.69,95.52,95.35,97.22,97.05,96.88
+ISO_04,98.965,99.77,99.425,100,97.22,97.05,96.88,96.71,96.54,96.37,96.2,96.03
+ISO_05,95.69,96.88,96.03,97.22,100,98.965,99.08,99.195,95.69,95.52,95.35,97.22
+ISO_06,95.52,96.71,95.86,97.05,98.965,100,99.885,98.85,96.88,96.71,96.54,96.37
+ISO_07,95.35,96.54,95.69,96.88,99.08,99.885,100,99.655,96.03,95.86,95.69,95.52
+ISO_08,97.22,96.37,95.52,96.71,99.195,98.85,99.655,100,97.22,97.05,96.88,96.71
+ISO_09,97.05,96.2,95.35,96.54,95.69,96.88,96.03,97.22,100,99.195,99.31,99.425
+ISO_10,96.88,96.03,97.22,96.37,95.52,96.71,95.86,97.05,99.195,100,98.965,99.08
+ISO_11,96.71,95.86,97.05,96.2,95.35,96.54,95.69,96.88,99.31,98.965,100,99.885
+ISO_12,96.54,95.69,96.88,96.03,97.22,96.37,95.52,96.71,99.425,99.08,99.885,100
From 0cb86d8ece141dcc00172b5e132e4d650ef6bcbf Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:29:23 +0200
Subject: [PATCH 066/134] feat: add synthetic teaching example
(examples/maldi.csv)
---
examples/maldi.csv | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 examples/maldi.csv
diff --git a/examples/maldi.csv b/examples/maldi.csv
new file mode 100644
index 0000000..9e6d68e
--- /dev/null
+++ b/examples/maldi.csv
@@ -0,0 +1,13 @@
+sample_id,ISO_01,ISO_02,ISO_03,ISO_04,ISO_05,ISO_06,ISO_07,ISO_08,ISO_09,ISO_10,ISO_11,ISO_12
+ISO_01,1,0.838,0.876,0.914,0.543,0.543,0.543,0.543,0.543,0.543,0.543,0.543
+ISO_02,0.838,1,0.52,0.876,0.387,0.387,0.387,0.387,0.387,0.387,0.387,0.387
+ISO_03,0.876,0.52,1,0.838,0.66,0.66,0.66,0.66,0.66,0.66,0.66,0.66
+ISO_04,0.914,0.876,0.838,1,0.88,0.504,0.504,0.504,0.504,0.504,0.504,0.504
+ISO_05,0.543,0.387,0.66,0.88,1,0.838,0.876,0.914,0.348,0.348,0.348,0.348
+ISO_06,0.543,0.387,0.66,0.504,0.838,1,0.838,0.876,0.621,0.621,0.621,0.621
+ISO_07,0.543,0.387,0.66,0.504,0.876,0.838,1,0.838,0.465,0.465,0.465,0.465
+ISO_08,0.543,0.387,0.66,0.504,0.914,0.876,0.838,1,0.309,0.309,0.309,0.309
+ISO_09,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,1,0.838,0.876,0.914
+ISO_10,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.838,1,0.838,0.876
+ISO_11,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.876,0.838,1,0.838
+ISO_12,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.914,0.876,0.838,1
From 040f15c801fd5aad4cb3f1ce0c7804f6063e6843 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:29:26 +0200
Subject: [PATCH 067/134] feat: add synthetic teaching example
(examples/metadata.csv)
---
examples/metadata.csv | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 examples/metadata.csv
diff --git a/examples/metadata.csv b/examples/metadata.csv
new file mode 100644
index 0000000..d91f2a0
--- /dev/null
+++ b/examples/metadata.csv
@@ -0,0 +1,13 @@
+sample_id,group,source,batch
+ISO_01,Group_A,urine,Batch_1
+ISO_02,Group_A,blood,Batch_2
+ISO_03,Group_A,stool,Batch_1
+ISO_04,Group_A,urine,Batch_2
+ISO_05,Group_B,blood,Batch_1
+ISO_06,Group_B,stool,Batch_2
+ISO_07,Group_B,urine,Batch_1
+ISO_08,Group_B,blood,Batch_2
+ISO_09,Group_C,stool,Batch_1
+ISO_10,Group_C,urine,Batch_2
+ISO_11,Group_C,blood,Batch_1
+ISO_12,Group_C,stool,Batch_2
From 7ee5572fc507570e643b7a664b99c9177596b3a5 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:29:29 +0200
Subject: [PATCH 068/134] feat: add synthetic teaching example
(R/example_data.R)
---
R/example_data.R | 55 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 55 insertions(+)
create mode 100644 R/example_data.R
diff --git a/R/example_data.R b/R/example_data.R
new file mode 100644
index 0000000..2be4d13
--- /dev/null
+++ b/R/example_data.R
@@ -0,0 +1,55 @@
+# Built-in teaching example. All values are synthetic, not measured isolates.
+manir_example_files <- function(root = ".") {
+ c(ANI = file.path(root, "examples", "ani.csv"),
+ MALDI = file.path(root, "examples", "maldi.csv"),
+ metadata = file.path(root, "examples", "metadata.csv"))
+}
+
+manir_load_example <- function(root = ".") {
+ paths <- manir_example_files(root)
+ missing <- paths[!file.exists(paths)]
+ if (length(missing)) {
+ stop("Built-in example files are missing: ",
+ paste(basename(missing), collapse = ", "))
+ }
+ ani <- read_matrix_input(paths[["ANI"]], kind = "similarity")
+ maldi <- read_matrix_input(paths[["MALDI"]], kind = "similarity")
+ matched <- match_matrices(ani, maldi)
+ metadata <- read_metadata_input(paths[["metadata"]])
+ if (!setequal(rownames(metadata), matched$ids)) {
+ stop("Built-in example metadata does not match the matrix identifiers.")
+ }
+ list(first = ani, second = maldi,
+ metadata = align_metadata(metadata, matched$ids))
+}
+
+manir_write_example_workbook <- function(destination, root = ".") {
+ example <- manir_load_example(root)
+ wb <- openxlsx::createWorkbook()
+ for (name in c("ANI", "MALDI")) {
+ m <- if (name == "ANI") example$first else example$second
+ openxlsx::addWorksheet(wb, name)
+ openxlsx::writeData(wb, name,
+ data.frame(sample_id = rownames(m), m, check.names = FALSE))
+ openxlsx::freezePane(wb, name, firstRow = TRUE, firstCol = TRUE)
+ }
+ openxlsx::addWorksheet(wb, "metadata")
+ openxlsx::writeData(wb, "metadata",
+ data.frame(sample_id = rownames(example$metadata),
+ example$metadata, check.names = FALSE))
+ openxlsx::freezePane(wb, "metadata", firstRow = TRUE, firstCol = TRUE)
+ openxlsx::addWorksheet(wb, "README")
+ openxlsx::writeData(wb, "README", data.frame(
+ Notes = c(
+ "MAniR built-in example. All data are SYNTHETIC and intended for demonstration only.",
+ "ANI: fictional percentage-like genomic similarity, diagonal = 100.",
+ "MALDI: fictional spectral similarity on a 0-to-1 scale, diagonal = 1.",
+ "The two matrices use different scales. Do not enable comparable-units metrics.",
+ "Metadata includes three fictional groups, specimen source and measurement batch.",
+ "Two pairs deliberately have discordant MALDI/ANI relationships for exploration.",
+ "Select ANI as matrix 1, MALDI as matrix 2, and metadata as the annotation sheet."
+ ), check.names = FALSE))
+ openxlsx::setColWidths(wb, "README", cols = 1, widths = 100)
+ openxlsx::saveWorkbook(wb, destination, overwrite = TRUE)
+ invisible(destination)
+}
From e4f774b8815fe894b4e04262d49262d49f59c7bf Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:30:11 +0200
Subject: [PATCH 069/134] feat: load bundled synthetic example helpers on app
startup
---
app.R | 1 +
1 file changed, 1 insertion(+)
diff --git a/app.R b/app.R
index 9a96bae..7218bb3 100644
--- a/app.R
+++ b/app.R
@@ -13,6 +13,7 @@ options(shiny.maxRequestSize = max_upload_mb * 1024^2)
source("R/matrix_io.R", local = TRUE)
source("R/matrix_analysis.R", local = TRUE)
source("R/matrix_plot.R", local = TRUE)
+source("R/example_data.R", local = TRUE)
source("ui.R", local = TRUE)
source("server.R", local = TRUE)
shiny::shinyApp(ui = ui, server = server)
From f3b5f74783a906190c903c95e314a6fc8fad0865 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:30:14 +0200
Subject: [PATCH 070/134] feat: add one-click synthetic example and XLSX
download to sidebar
---
ui.R | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/ui.R b/ui.R
index 88fc355..007222a 100644
--- a/ui.R
+++ b/ui.R
@@ -18,7 +18,18 @@ ui <- shiny::fluidPage(
shiny::p("Explore and compare pairwise similarity, correlation or distance matrices."),
shiny::sidebarLayout(
shiny::sidebarPanel(width = 3,
- shiny::h4("Input data"),
+ shiny::div(class = "metric",
+ shiny::h4("Try an example"),
+ shiny::p("Explore 12 fictional isolates, two similarity matrices and sample metadata. No upload needed."),
+ shiny::actionButton("load_example", "Load example dataset",
+ class = "btn-primary"),
+ shiny::tags$br(), shiny::tags$br(),
+ shiny::downloadButton("download_example", "Download example XLSX"),
+ shiny::p(class = "small-help",
+ "Synthetic data for demonstration, not real biological measurements.")
+ ),
+ shiny::hr(),
+ shiny::h4("Upload your data"),
shiny::p(class = "small-help",
"For very large matrices, CSV or RDS is preferable to Excel.
Import uses RAM for the matrix and temporary validation data."),
From a80e1305cd888cb06dabea0d250a4152ac5b64f0 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:30:17 +0200
Subject: [PATCH 071/134] feat: load example without uploading and export
workbook with metadata
---
server.R | 56 +++++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 43 insertions(+), 13 deletions(-)
diff --git a/server.R b/server.R
index cc887ad..f7c1139 100644
--- a/server.R
+++ b/server.R
@@ -32,12 +32,25 @@ server <- function(input, output, session) {
choices = c("None", sheets), selected = suggested)
})
- loaded <- shiny::eventReactive(input$visualize, {
- shiny::req(input$first_file)
+ analyze_input <- function(example = FALSE) {
+ if (!example) shiny::req(input$first_file)
shiny::withProgress(message = "Validating matrices and computing sample order",
value = 0, {
tryCatch({
- f <- input$first_file
+ if (example) {
+ demo <- manir_load_example()
+ a <- demo$first
+ b <- demo$second
+ meta <- demo$metadata
+ kind1 <- "similarity"
+ kind2 <- "similarity"
+ match_mode <- "strict"
+ shiny::incProgress(0.40)
+ } else {
+ kind1 <- input$kind1
+ kind2 <- input$kind2
+ match_mode <- input$match_mode
+ f <- input$first_file
wb1 <- if (is_excel(f)) openxlsx::loadWorkbook(f$datapath) else NULL
a <- read_matrix_input(f$datapath,
sheet = if (is_excel(f)) input$first_sheet else NULL,
@@ -78,11 +91,12 @@ server <- function(input, output, session) {
} else if (!is.null(input$metadata_file) && !is_excel(fmeta)) {
meta <- read_metadata_input(fmeta$datapath, format = fmeta$name)
}
- if (!is.null(meta) && !all(colnames(a) %in% rownames(meta)))
- shiny::showNotification("Some matrix samples have no metadata.",
- type = "warning", duration = 8)
+ if (!is.null(meta) && !all(colnames(a) %in% rownames(meta)))
+ shiny::showNotification("Some matrix samples have no metadata.",
+ type = "warning", duration = 8)
+ }
explicit <- NULL
- if (!is.null(input$order_file)) {
+ if (!example && !is.null(input$order_file)) {
explicit <- trimws(readLines(input$order_file$datapath, warn = FALSE))
explicit <- sub("[,\t].*$", "", explicit)
explicit <- explicit[nzchar(explicit) & explicit != "sample_id"]
@@ -97,13 +111,14 @@ server <- function(input, output, session) {
matrix_order(m, kind, cluster = isTRUE(input$cluster),
linkage = input$linkage)$ids
}
- order1 <- order_for(a, input$kind1)
+ order1 <- order_for(a, kind1)
shiny::incProgress(0.30)
- order2 <- if (!is.null(b)) order_for(b, input$kind2) else NULL
+ order2 <- if (!is.null(b)) order_for(b, kind2) else NULL
shiny::incProgress(0.30)
shiny::updateSelectInput(session, "metadata_column",
choices = c("None" = "", if (!is.null(meta)) names(meta)),
- selected = "")
+ selected = if (example && !is.null(meta) && "group" %in% names(meta))
+ "group" else "")
shiny::showNotification(
sprintf("Loaded %d isolates%s.", nrow(a),
if (!is.null(b)) paste0(" and ", nrow(b), " in matrix 2")
@@ -111,11 +126,12 @@ server <- function(input, output, session) {
type = "message", duration = 4)
list(first = a, second = b, metadata = meta,
order1 = order1, order2 = order2,
- matching = input$match_mode,
+ source = if (example) "synthetic example" else "uploaded files",
+ matching = match_mode,
clustering_skipped = isTRUE(input$cluster) &&
is.null(explicit) &&
(nrow(a) > 2000L || (!is.null(b) && nrow(b) > 2000L)),
- kind1 = input$kind1, kind2 = input$kind2,
+ kind1 = kind1, kind2 = kind2,
linkage = input$linkage)
}, error = function(e) {
shiny::showNotification(conditionMessage(e), type = "error",
@@ -123,7 +139,18 @@ server <- function(input, output, session) {
NULL
})
})
- }, ignoreNULL = TRUE)
+ }
+ loaded <- shiny::reactiveVal(NULL)
+ shiny::observeEvent(input$visualize, {
+ loaded(analyze_input(example = FALSE))
+ })
+ shiny::observeEvent(input$load_example, {
+ loaded(analyze_input(example = TRUE))
+ })
+ output$download_example <- shiny::downloadHandler(
+ filename = function() "MAniR_synthetic_example.xlsx",
+ content = function(file) manir_write_example_workbook(file)
+ )
data <- shiny::reactive({ shiny::req(loaded()); loaded() })
has_two <- shiny::reactive(!is.null(data()$second))
@@ -182,6 +209,9 @@ server <- function(input, output, session) {
d <- data()
shiny::tagList(
shiny::h3("Dataset overview"),
+ shiny::p(if (identical(d$source, "synthetic example"))
+ "Synthetic teaching dataset. These are fictional similarities, not measured biological results."
+ else "Uploaded dataset."),
shiny::div(class = "metric", sprintf("Matrix 1: %s isolates",
format(nrow(d$first), big.mark = ","))),
if (!is.null(d$second))
From a268676cbee5368522cc337e3b5832097d8f2731 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:30:53 +0200
Subject: [PATCH 072/134] test: verify synthetic example matrices, metadata and
downloaded XLSX roundtrip
---
tests/run_tests.R | 39 +++++++++++++++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index c3a4994..46dd469 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -2,6 +2,7 @@
source("R/matrix_io.R")
source("R/matrix_analysis.R")
source("R/matrix_plot.R")
+source("R/example_data.R")
expect_error <- function(expr, pattern = NULL) {
e <- tryCatch({force(expr); NULL}, error = function(e) conditionMessage(e))
@@ -216,4 +217,42 @@ test("CSV import preserves leading zeroes in sample IDs", {
stopifnot(identical(rownames(y), id), identical(colnames(y), id))
})
+
+test("built-in synthetic ANI/MALDI example is valid and includes metadata", {
+ demo <- manir_load_example()
+ stopifnot(
+ identical(dim(demo$first), c(12L, 12L)),
+ identical(dim(demo$second), c(12L, 12L)),
+ identical(dimnames(demo$first), dimnames(demo$second)),
+ all(diag(demo$first) == 100),
+ all(diag(demo$second) == 1),
+ identical(rownames(demo$metadata), rownames(demo$first)),
+ identical(names(demo$metadata), c("group", "source", "batch")),
+ length(unique(demo$metadata$group)) == 3L,
+ abs(demo$second["ISO_02", "ISO_03"] - 0.52) < 1e-12,
+ abs(demo$second["ISO_04", "ISO_05"] - 0.88) < 1e-12
+ )
+ values <- paired_values(demo$first, demo$second)
+ stopifnot(!values$sampled, values$valid_pairs == 66L)
+})
+
+test("downloadable XLSX example round-trips all matrices and metadata", {
+ demo <- manir_load_example()
+ path <- tempfile(fileext = ".xlsx")
+ manir_write_example_workbook(path)
+ stopifnot(file.exists(path), file.info(path)$size > 0)
+ stopifnot(identical(openxlsx::getSheetNames(path),
+ c("ANI", "MALDI", "metadata", "README")))
+ first <- read_matrix_input(path, sheet = "ANI")
+ second <- read_matrix_input(path, sheet = "MALDI")
+ metadata <- read_metadata_input(path, sheet = "metadata")
+ stopifnot(
+ isTRUE(all.equal(first, demo$first, check.attributes = FALSE)),
+ isTRUE(all.equal(second, demo$second, check.attributes = FALSE)),
+ identical(metadata, demo$metadata)
+ )
+ guide <- openxlsx::read.xlsx(path, sheet = "README")
+ stopifnot(grepl("SYNTHETIC", guide$Notes[1L]))
+})
+
cat("All MAniR regression tests passed.\n")
From 4d8297c5a870e9871cdbd0b2b774191bcea6f15d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:30:57 +0200
Subject: [PATCH 073/134] test: check one-click example loading and switching
back to uploads
---
tests/test_shiny.R | 28 +++++++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/tests/test_shiny.R b/tests/test_shiny.R
index 46cead2..0b275b1 100644
--- a/tests/test_shiny.R
+++ b/tests/test_shiny.R
@@ -2,6 +2,7 @@
source("R/matrix_io.R")
source("R/matrix_analysis.R")
source("R/matrix_plot.R")
+source("R/example_data.R")
source("server.R")
ids <- c("001", "002", "003", "004")
@@ -32,4 +33,29 @@ shiny::testServer(server, {
session$setInputs(palette = "Viridis")
stopifnot(identical(loaded()$first, loaded_data$first))
})
-cat("Shiny upload and reactive smoke test passed.\n")
+
+shiny::testServer(server, {
+ session$setInputs(kind1 = "similarity", kind2 = "similarity",
+ cluster = FALSE, linkage = "complete",
+ match_mode = "strict", load_example = 1)
+ demo <- loaded()
+ stopifnot(
+ !is.null(demo),
+ identical(demo$source, "synthetic example"),
+ nrow(demo$first) == 12L, nrow(demo$second) == 12L,
+ nrow(demo$metadata) == 12L,
+ identical(demo$matching, "strict")
+ )
+ # An ordinary upload after the example must replace it without carrying
+ # across the example metadata or second matrix.
+ session$setInputs(first_file = file_info, visualize = 1)
+ own <- loaded()
+ stopifnot(identical(own$source, "uploaded files"),
+ nrow(own$first) == 4L, is.null(own$second),
+ is.null(own$metadata))
+ session$setInputs(load_example = 2)
+ stopifnot(identical(loaded()$source, "synthetic example"),
+ nrow(loaded()$second) == 12L)
+})
+
+cat("Shiny upload and example reactive smoke tests passed.\n")
From d6b06781c551141c08a15fec139c16099e68c597 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:30:59 +0200
Subject: [PATCH 074/134] docs: explain synthetic teaching data and example
download
---
README.md | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/README.md b/README.md
index b368efd..874bb91 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,28 @@ shiny::runApp(".")
For scripted validation, run `Rscript tests/run_tests.R`. For repeatable performance measurements, run `Rscript benchmarks/benchmark.R` and retain the generated CSV. To compare representative original and new plotting pipelines, install the optional `corrplot` and `heatmaply` packages and run `Rscript benchmarks/compare_original.R`. The optional `--smoke` flag runs a short check; `--large` includes 10,000-isolate data and requires substantial RAM.
+## Built-in example
+
+Click **Load example dataset** in the application sidebar to explore MAniR
+without uploading anything. The example contains 12 **fictional** isolates in
+three groups, with two precomputed symmetric similarity matrices:
+
+- **ANI:** synthetic percentage-like genomic similarity (100 on the diagonal).
+- **MALDI:** synthetic spectral similarity on a 0–1 scale (1 on the diagonal).
+- **Metadata:** fictional group, specimen source and measurement batch.
+
+Two isolate pairs have intentionally discordant MALDI and ANI relationships,
+making the example useful for exploring split-triangle views, clustering,
+pairwise scatterplots, discrepancies and metadata tracks. The measurement
+scales are different, so leave "Both matrices use comparable numerical units"
+unchecked. All entries are illustrative and must not be used for biological
+inference or cited as measured data.
+
+Click **Download example XLSX** to obtain a workbook with `ANI`, `MALDI`,
+`metadata` and `README` sheets. The equivalent editable CSV files are in
+`examples/`. To upload the workbook yourself, select ANI as the first sheet,
+MALDI as the second and metadata as the annotation sheet.
+
## Input formats
- **XLSX / XLSM:** one matrix per worksheet, with sample IDs in the first row and first column. Optional second matrix and metadata sheets may be selected from the same workbook. A second workbook is also supported.
@@ -71,6 +93,8 @@ server.R Session-specific upload, analysis and lazy render logic
R/matrix_io.R Format readers, sample ID validation and alignment
R/matrix_analysis.R Pairwise summaries, clustering, permutations
R/matrix_plot.R Interactive and sampled raster rendering
+R/example_data.R Example loading and XLSX download generation
+examples/ Synthetic ANI, MALDI and metadata CSVs
tests/run_tests.R Numeric, import, statistics and rendering regressions
benchmarks/benchmark.R Repeated staged scaling measurements
docs/UPGRADE_WORKLOG.md Implementation scope and release criteria
From 792d42a3e1b35e646b7060284f87bf9219c5fd36 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:31:02 +0200
Subject: [PATCH 075/134] docs: record synthetic example and added validation
coverage
---
docs/UPGRADE_WORKLOG.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/docs/UPGRADE_WORKLOG.md b/docs/UPGRADE_WORKLOG.md
index 0325458..f5bd098 100644
--- a/docs/UPGRADE_WORKLOG.md
+++ b/docs/UPGRADE_WORKLOG.md
@@ -73,3 +73,8 @@ Verification still outstanding:
Do not merge or tag this development branch as a validated release until
these checks pass. The arXiv manuscript is outside this work scope.
+
+- Added a self-contained, clearly labeled synthetic 12-isolate example with
+ matching ANI- and MALDI-like matrices and metadata. The upgraded interface
+ offers a one-click loader and an XLSX download; numerical and Shiny tests
+ cover both the demo and switching back to user uploads.
From 74b83cf731d731dadcba2bb849131ce5d50e5812 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:32:04 +0200
Subject: [PATCH 076/134] data: vary synthetic between-group MALDI similarities
across isolate pairs
---
examples/maldi.csv | 24 ++++++++++++------------
1 file changed, 12 insertions(+), 12 deletions(-)
diff --git a/examples/maldi.csv b/examples/maldi.csv
index 9e6d68e..70c246d 100644
--- a/examples/maldi.csv
+++ b/examples/maldi.csv
@@ -1,13 +1,13 @@
sample_id,ISO_01,ISO_02,ISO_03,ISO_04,ISO_05,ISO_06,ISO_07,ISO_08,ISO_09,ISO_10,ISO_11,ISO_12
-ISO_01,1,0.838,0.876,0.914,0.543,0.543,0.543,0.543,0.543,0.543,0.543,0.543
-ISO_02,0.838,1,0.52,0.876,0.387,0.387,0.387,0.387,0.387,0.387,0.387,0.387
-ISO_03,0.876,0.52,1,0.838,0.66,0.66,0.66,0.66,0.66,0.66,0.66,0.66
-ISO_04,0.914,0.876,0.838,1,0.88,0.504,0.504,0.504,0.504,0.504,0.504,0.504
-ISO_05,0.543,0.387,0.66,0.88,1,0.838,0.876,0.914,0.348,0.348,0.348,0.348
-ISO_06,0.543,0.387,0.66,0.504,0.838,1,0.838,0.876,0.621,0.621,0.621,0.621
-ISO_07,0.543,0.387,0.66,0.504,0.876,0.838,1,0.838,0.465,0.465,0.465,0.465
-ISO_08,0.543,0.387,0.66,0.504,0.914,0.876,0.838,1,0.309,0.309,0.309,0.309
-ISO_09,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,1,0.838,0.876,0.914
-ISO_10,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.838,1,0.838,0.876
-ISO_11,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.876,0.838,1,0.838
-ISO_12,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.914,0.876,0.838,1
+ISO_01,1,0.914,0.819,0.895,0.504,0.582,0.66,0.309,0.387,0.465,0.543,0.621
+ISO_02,0.914,1,0.52,0.857,0.348,0.426,0.504,0.582,0.66,0.309,0.387,0.465
+ISO_03,0.819,0.52,1,0.819,0.621,0.27,0.348,0.426,0.504,0.582,0.66,0.309
+ISO_04,0.895,0.857,0.819,1,0.88,0.543,0.621,0.27,0.348,0.426,0.504,0.582
+ISO_05,0.504,0.348,0.621,0.88,1,0.895,0.8,0.876,0.621,0.27,0.348,0.426
+ISO_06,0.582,0.426,0.27,0.543,0.895,1,0.933,0.838,0.465,0.543,0.621,0.27
+ISO_07,0.66,0.504,0.348,0.621,0.8,0.933,1,0.8,0.309,0.387,0.465,0.543
+ISO_08,0.309,0.582,0.426,0.27,0.876,0.838,0.8,1,0.582,0.66,0.309,0.387
+ISO_09,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.582,1,0.876,0.952,0.857
+ISO_10,0.465,0.309,0.582,0.426,0.27,0.543,0.387,0.66,0.876,1,0.914,0.819
+ISO_11,0.543,0.387,0.66,0.504,0.348,0.621,0.465,0.309,0.952,0.914,1,0.952
+ISO_12,0.621,0.465,0.309,0.582,0.426,0.27,0.543,0.387,0.857,0.819,0.952,1
From 8b749196a6cf69a37cb60c71facee59f9dd041f2 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:41:23 +0200
Subject: [PATCH 077/134] feat: add scale-independent rank-gap exploration for
pairwise comparisons
---
R/matrix_analysis.R | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/R/matrix_analysis.R b/R/matrix_analysis.R
index 1f70afd..83e8183 100644
--- a/R/matrix_analysis.R
+++ b/R/matrix_analysis.R
@@ -194,3 +194,25 @@ replicate_summary <- function(m, metadata, group_column, max_pairs = 100000L) {
if (any(!within)) stats::median(values[!within]) else NA_real_),
sampled = pp$sampled)
}
+
+# Exploratory comparison of the order of isolate pairs when measurements have
+# different units. A gap is the difference between empirical percentile ranks,
+# after making smaller distances mean greater similarity. It is not a p-value
+# or a biological cutoff, and it is based on the sampled pairs when sampling.
+pair_rank_gaps <- function(pairs, kind1 = "similarity",
+ kind2 = "similarity", top = 10L) {
+ d <- pairs$data
+ if (nrow(d) < 3L) return(data.frame())
+ if (!kind1 %in% c("auto", "similarity", "correlation", "distance") ||
+ !kind2 %in% c("auto", "similarity", "correlation", "distance"))
+ stop("Unrecognized matrix type.")
+ score1 <- if (kind1 == "distance") -d$first else d$first
+ score2 <- if (kind2 == "distance") -d$second else d$second
+ n <- nrow(d)
+ d$rank_gap <- abs((rank(score1, ties.method = "average") -
+ rank(score2, ties.method = "average")) / n)
+ d <- d[order(d$rank_gap, decreasing = TRUE), , drop = FALSE]
+ rownames(d) <- NULL
+ utils::head(d[c("sample_1", "sample_2", "first", "second", "rank_gap")],
+ as.integer(top))
+}
From be37a43ff3d1b7f0237a2b3c963c50ed2b6d13ac Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:41:48 +0200
Subject: [PATCH 078/134] fix: put categorical metadata strip above Plotly
heatmaps and move legend outside plotting area
---
R/matrix_plot.R | 60 +++++++++++++++++++++++++++++++------------------
1 file changed, 38 insertions(+), 22 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 1228170..67a6b7d 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -55,6 +55,22 @@ ma_combined <- function(first, second, log_scale = FALSE) {
second_range = attr(b, "original_range"))
}
+# Deterministic categorical colors so a group retains its color when a
+# different matrix ordering is selected. Large category lists are omitted
+# from the compact track; the full values remain in the metadata table.
+ma_metadata_palette <- function(metadata, group_column, max_categories = 12L) {
+ if (is.null(metadata) || is.null(group_column) ||
+ length(group_column) != 1L || is.na(group_column) ||
+ !nzchar(group_column) || !group_column %in% names(metadata))
+ return(NULL)
+ values <- as.character(metadata[[group_column]])
+ categories <- sort(unique(values[!is.na(values) & nzchar(values)]))
+ if (length(categories) == 0L || length(categories) > max_categories)
+ return(NULL)
+ stats::setNames(
+ grDevices::hcl.colors(length(categories), "Set 2"), categories)
+}
+
# Plotly is intentionally limited to manageable interactive views; the
# large-matrix renderer does not transmit hundreds of thousands of cell labels.
ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
@@ -85,23 +101,25 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
digits = 8L, trim = TRUE))
}
shapes <- NULL
+ palette_key <- ma_metadata_palette(metadata, group_column)
if (!is.null(metadata) && !is.null(group_column) &&
- isTRUE(nzchar(group_column)) && group_column %in% names(metadata)) {
+ length(group_column) == 1L && !is.na(group_column) &&
+ nzchar(group_column) && group_column %in% names(metadata)) {
groups <- as.character(metadata[match(ids, rownames(metadata)), group_column])
group_text <- htmltools::htmlEscape(ifelse(is.na(groups), "Missing", groups))
for (j in seq_len(n))
hover[, j] <- paste0(hover[, j], "
",
htmltools::htmlEscape(group_column), ": ",
group_text)
- classes <- unique(groups[!is.na(groups)])
- if (length(classes) <= 30L && length(classes) > 0L) {
- category_colors <- grDevices::hcl.colors(length(classes), "Set 2")
- selected <- category_colors[match(groups, classes)]
+ if (!is.null(palette_key)) {
+ selected <- unname(palette_key[groups])
selected[is.na(selected)] <- "#E5E5E5"
+ # Draw the metadata strip ABOVE the heatmap rather than on its left.
+ # That keeps the y-axis isolate IDs unobstructed.
shapes <- lapply(seq_len(n), function(i) {
- list(type = "rect", xref = "paper", yref = "y",
- x0 = -0.035, x1 = -0.012,
- y0 = n - i - 0.45, y1 = n - i + 0.45,
+ list(type = "rect", xref = "x", yref = "paper",
+ x0 = i - 1.45, x1 = i - 0.55,
+ y0 = 1.015, y1 = 1.045,
fillcolor = selected[i], line = list(width = 0))
})
}
@@ -131,7 +149,8 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
automargin = TRUE),
yaxis = list(title = "", automargin = TRUE),
shapes = shapes,
- margin = list(l = 130, b = 130, r = 30, t = 55))
+ margin = list(l = 95, b = 130, r = 30, t = 95),
+ colorbar = list(title = "Relative display color"))
}
# Representative-pixel preview: never use the reduced view for numerical
@@ -172,19 +191,16 @@ ma_raster <- function(m, palette = "RdBu", title = "",
box()
if (!is.null(metadata) && !is.null(group_column) && nzchar(group_column) &&
group_column %in% colnames(metadata)) {
- groups <- as.character(metadata[match(rownames(x), rownames(metadata)), group_column])
- valid <- !is.na(groups)
- levels <- unique(groups[valid])
- annotation_colors <- grDevices::hcl.colors(max(1L, length(levels)), "Set 2")
- fill <- annotation_colors[match(groups, levels)]
- if (any(valid)) {
- # The left-hand color strip represents the selected metadata field.
- graphics::rect(0.06, n - which(valid) + 0.5, 0.40,
- n - which(valid) + 1.5, col = fill[valid], border = NA)
- if (length(levels) <= 12L) {
- graphics::legend("topright", legend = levels, fill = annotation_colors,
- cex = 0.55, bty = "n")
- }
+ groups <- as.character(metadata[match(rownames(x), rownames(metadata)),
+ group_column])
+ key <- ma_metadata_palette(metadata, group_column)
+ if (!is.null(key)) {
+ fill <- unname(key[groups])
+ fill[is.na(fill)] <- "#E5E5E5"
+ # The annotation strip sits in the narrow gap outside the heatmap.
+ # Its legend is displayed in a separate panel above the Shiny plot.
+ graphics::rect(0.06, n - seq_len(n) + 0.5, 0.40,
+ n - seq_len(n) + 1.5, col = fill, border = NA)
}
}
if (preview$sampled)
From f68b5ea220195ac9d9fbe865692435d2d13a4196 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:42:11 +0200
Subject: [PATCH 079/134] fix: attach relative color legend to Plotly heatmap
trace
---
R/matrix_plot.R | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/R/matrix_plot.R b/R/matrix_plot.R
index 67a6b7d..51ffee4 100644
--- a/R/matrix_plot.R
+++ b/R/matrix_plot.R
@@ -134,7 +134,8 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
text = hover[n:1L, , drop = FALSE],
type = "heatmap", colors = ma_colors(palette),
zmin = 0, zmax = 1, hoverinfo = "text",
- showscale = TRUE
+ showscale = TRUE,
+ colorbar = list(title = "Relative display color (0-1)")
)
if (!is.null(note)) {
# Text labels only for small matrices; large labels overload the browser.
@@ -149,8 +150,7 @@ ma_interactive <- function(m, name = "Matrix", palette = "RdBu",
automargin = TRUE),
yaxis = list(title = "", automargin = TRUE),
shapes = shapes,
- margin = list(l = 95, b = 130, r = 30, t = 95),
- colorbar = list(title = "Relative display color"))
+ margin = list(l = 95, b = 130, r = 30, t = 95))
}
# Representative-pixel preview: never use the reduced view for numerical
From dfa4dec121539cb554adee71ea2023ed5c20e1f3 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:42:55 +0200
Subject: [PATCH 080/134] docs(ui): add contextual interpretation guides for
every analysis tab and measurement option
---
ui.R | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 95 insertions(+), 12 deletions(-)
diff --git a/ui.R b/ui.R
index 007222a..bec18ef 100644
--- a/ui.R
+++ b/ui.R
@@ -1,4 +1,15 @@
# MAniR: matrix comparison and scalable visualization.
+manir_guide <- function(lead, details = NULL) {
+ shiny::div(class = "reading-guide",
+ shiny::strong("How to read this"),
+ shiny::p(lead),
+ if (!is.null(details))
+ shiny::tags$details(
+ shiny::tags$summary("More explanation"),
+ shiny::p(details)
+ )
+ )
+}
ui <- shiny::fluidPage(
shiny::tags$head(
shiny::tags$meta(name = "viewport", content = "width=device-width, initial-scale=1"),
@@ -12,6 +23,22 @@ ui <- shiny::fluidPage(
.metric { padding: 10px; margin: 6px 0; background: #f2f6f9;
border-left: 3px solid #487f9b; }
.tab-content { padding-top: 15px; }
+ .reading-guide { border-left: 3px solid #3c7494; background: #eef5fa;
+ padding: 12px 16px; margin: 12px 0 20px; color: #234257;
+ line-height: 1.45; border-radius: 4px; }
+ .reading-guide p { margin: 6px 0 0; }
+ .reading-guide details { margin-top: 8px; }
+ .reading-guide summary { cursor: pointer; font-weight: 600; }
+ .reading-guide details p { margin-top: 8px; max-width: 88ch; }
+ .metadata-key { display: flex; flex-wrap: wrap; align-items: center;
+ gap: 7px 16px; margin: 8px 0 3px; font-size: 13px; }
+ .metadata-key .swatch { display: inline-block; width: 15px; height: 13px;
+ border-radius: 2px; margin-right: 5px;
+ vertical-align: middle; }
+ .metadata-key .key-title { font-weight: 600; color: #29465c; }
+ .section-note { margin: 13px 0 8px; color: #3b5668; }
+ .results-table { margin: 12px 0 20px; }
+ .main-panel .btn { margin: 7px 7px 7px 0; }
"))
),
shiny::titlePanel("MAniR"),
@@ -51,6 +78,8 @@ ui <- shiny::fluidPage(
shiny::selectInput("kind2", "Matrix 2 represents",
choices = c("Similarity" = "similarity", "Correlation" = "correlation",
"Distance" = "distance", "Unspecified" = "auto")),
+ shiny::p(class = "small-help",
+ "Similarity: larger values mean closer samples. Distance: smaller values mean closer samples. Correlation ranges from -1 to +1; larger values indicate a more positive relationship."),
shiny::radioButtons("match_mode", "Compare samples",
choices = c("Require identical IDs" = "strict",
"Shared isolates only" = "intersection"),
@@ -78,54 +107,108 @@ ui <- shiny::fluidPage(
shiny::selectInput("metadata_column", "Categorical annotation track",
choices = c("None" = "")),
shiny::checkboxInput("comparable_scales",
- "Both matrices use comparable numerical units", value = FALSE),
+ "Both matrices measure the same quantity on the same scale",
+ value = FALSE),
+ shiny::p(class = "small-help",
+ "Check this only for directly comparable measurements, such as ANI from two sequencing runs. Do not check it for ANI percentages versus MALDI scores, even after normalizing the heatmap colors."),
shiny::helpText("For large matrices, plots use representative pixels. Numerical analyses and exports use original values unless stated otherwise."),
shiny::hr(),
shiny::h4("Statistical analysis"),
shiny::numericInput("max_pairs", "Maximum plotted/analyzed pairs",
100000, min = 1000, max = 1000000, step = 1000),
- shiny::numericInput("cluster_k", "Clusters for concordance", 3,
+ shiny::numericInput("cluster_k", "Number of clusters to compare (k)", 3,
min = 2, max = 100),
+ shiny::p(class = "small-help",
+ "Cut both dendrograms into k groups and compare membership. Changing k can change every agreement statistic."),
shiny::numericInput("permutations", "Mantel permutations", 999,
min = 99, max = 9999),
shiny::actionButton("run_mantel", "Run Mantel test"),
shiny::p(class = "small-help",
- "Mantel tests require complete matrices and at most 400 isolates.")
+ "The Mantel test checks association between matrices by permuting whole isolate labels, not individual cells. It requires complete matrices and at most 400 shared isolates; its p-value is not a test of typing-method equivalence.")
),
shiny::mainPanel(width = 9,
shiny::div(class = "main-panel",
shiny::tabsetPanel(id = "results_tab",
shiny::tabPanel("Overview",
- shiny::uiOutput("overview"), shiny::tableOutput("agreement"),
+ manir_guide(
+ "Start with the number of shared isolates and the relationship between the two matrices. Correlations show whether pairs that are high in one measurement also tend to be high in the other; they do not establish equivalence.",
+ "Pearson's r summarizes a linear relationship. Spearman's rho compares ranks and can detect a monotonic relationship on different scales. The same isolate appears in several pairs, so ordinary independent-observation p-values would be inappropriate. Use the optional Mantel permutation test for matrix association."),
+ shiny::uiOutput("overview"),
+ shiny::h4("Association between matrices"),
+ shiny::tableOutput("agreement"),
+ shiny::uiOutput("mantel_note"),
shiny::verbatimTextOutput("mantel_result"),
+ shiny::h4("Within- and between-group values"),
+ shiny::p(class = "small-help",
+ "Select a metadata category in the sidebar to describe how the first matrix differs within and between those groups. These are descriptive pairwise summaries, not an independent-sample test."),
shiny::tableOutput("replicate_table")),
shiny::tabPanel("Matrix 1",
+ manir_guide(
+ "Every cell compares the isolate named on its row with the isolate named on its column. The diagonal compares an isolate with itself. Larger values mean more similar samples only for similarity and positive-correlation measurements.",
+ "Clustering puts isolates with related measurements next to each other; it does not change the values. The color bar shows a 0–1 display scale based on the data range, not necessarily the original numerical units. Hover over a cell for its original value. Metadata colors identify categories, not measured similarity."),
+ shiny::uiOutput("plot1_intro"), shiny::uiOutput("plot1_legend"),
shiny::uiOutput("plot1_ui"), shiny::verbatimTextOutput("plot1_cell")),
shiny::tabPanel("Matrix 2",
+ manir_guide(
+ "Read this matrix in the same way as matrix 1. Its clustering order can differ, and the display colors are scaled to this matrix's own numerical range.",
+ "Equal colors across the separate Matrix 1 and Matrix 2 plots do not imply equal biological measurements. Hover over any cell for the exact original value. Choose the same metadata track to see whether visual clusters correspond to the same sample categories."),
+ shiny::uiOutput("plot2_intro"), shiny::uiOutput("plot2_legend"),
shiny::uiOutput("plot2_ui"), shiny::verbatimTextOutput("plot2_cell")),
shiny::tabPanel("Combined (order 1)",
- shiny::uiOutput("combined1_ui"),
- shiny::verbatimTextOutput("combined1_cell")),
+ manir_guide(
+ "This split heatmap places matrix 1 above the diagonal and matrix 2 below it. Both triangles use the ordering calculated from matrix 1, so the same pair can be inspected in both methods.",
+ "Each triangle is normalized independently for display. Comparing the position of clusters is meaningful; directly comparing two color shades or subtracting their display values is not. The diagonal is displayed with a neutral midpoint color because it separates the two methods, not because its original measurements equal 0.5."),
+ shiny::uiOutput("combined1_intro"), shiny::uiOutput("combined1_legend"),
+ shiny::uiOutput("combined1_ui"), shiny::verbatimTextOutput("combined1_cell")),
shiny::tabPanel("Combined (order 2)",
- shiny::uiOutput("combined2_ui"),
- shiny::verbatimTextOutput("combined2_cell")),
+ manir_guide(
+ "The same split comparison, now ordered using matrix 2. The upper triangle is matrix 2 and the lower triangle is matrix 1.",
+ "Switch between both combined tabs to see how each method's ordering groups isolates. Triangle colors remain independently scaled and the diagonal is a visual separator. Inspect original values in the cell hover rather than using color as a shared measurement."),
+ shiny::uiOutput("combined2_intro"), shiny::uiOutput("combined2_legend"),
+ shiny::uiOutput("combined2_ui"), shiny::verbatimTextOutput("combined2_cell")),
shiny::tabPanel("Difference",
- shiny::uiOutput("difference_ui"),
- shiny::p("Only available when both matrices use comparable numerical units."),
+ manir_guide(
+ "This tab calculates matrix 1 minus matrix 2 for each shared isolate pair. Zero is the midpoint of the diverging color scale; opposite sides represent opposite signs.",
+ "A difference is interpretable only when both matrices measure the same quantity in the same units and have compatible preprocessing. Two different measurement types, such as ANI percentages and MALDI spectral scores, should be examined with rank comparisons instead."),
+ shiny::uiOutput("difference_explainer"), shiny::uiOutput("difference_ui"),
shiny::verbatimTextOutput("difference_cell")),
shiny::tabPanel("Pairwise comparison",
+ manir_guide(
+ "Each dot is one distinct, unordered isolate pair. Its X position is the pair's value in matrix 1 and its Y position is the value for the same pair in matrix 2. Neither the diagonal nor mirrored duplicates are counted.",
+ "An upward pattern suggests positive association, while points far from the main pattern merit closer inspection. Different units are allowed here: compare ranks or correlations, not the raw distance from a one-to-one line. Pairwise observations share isolates, so they are not statistically independent."),
+ shiny::uiOutput("pairwise_intro"),
shiny::plotOutput("scatter", height = "520px"),
+ shiny::h4("Pairs with the largest rank differences"),
+ shiny::p(class = "small-help",
+ "These are exploratory discrepancies in the relative ordering of pairs, not statistically significant outliers. A rank gap near 0 means the pair has a similar position among all evaluated pairs in both matrices. Orange rings mark up to five of these pairs in the scatterplot."),
+ shiny::tableOutput("rank_gaps"),
+ shiny::uiOutput("absolute_discrepancies_intro"),
shiny::tableOutput("discrepancies"),
shiny::downloadButton("download_pairs", "Download pairwise values (CSV)")),
shiny::tabPanel("Cluster comparison",
+ manir_guide(
+ "Each matrix is independently clustered and cut into the same chosen number of groups (k). The statistics compare which isolate pairs are grouped together, regardless of the arbitrary cluster numbers.",
+ "Adjusted Rand index (ARI) equals 1 for identical partitions and is near 0 for chance-level agreement under its adjustment model. Adjusted Wallace is directional: matrix 1 to matrix 2 asks whether pairs grouped together in the first remain together in the second, adjusted for chance. Reverse the direction for the other value. These numbers describe agreement, not biological accuracy or a probability that either method is correct."),
+ shiny::uiOutput("cluster_intro"),
shiny::tableOutput("cluster_summary"),
+ shiny::h4("How the clusters overlap"),
+ shiny::tableOutput("cluster_overlap"),
+ shiny::h4("Cluster membership by isolate"),
+ shiny::p(class = "small-help",
+ "Cluster numbers are arbitrary. Cluster 1 from one matrix is not necessarily the same group as cluster 1 from the other."),
shiny::tableOutput("cluster_table"),
shiny::downloadButton("download_clusters", "Download cluster assignments")),
shiny::tabPanel("Metadata",
+ manir_guide(
+ "Metadata describe each isolate, such as its group, specimen source or experimental batch. Select a categorical field in the sidebar to show a colored annotation track next to the heatmaps.",
+ "Metadata categories are not automatically cluster labels or validation truth. A batch-associated cluster may reflect an experimental effect and needs separate investigation. Numeric metadata should not be interpreted as categories unless intentionally converted."),
shiny::tableOutput("metadata_preview"),
shiny::uiOutput("metadata_note")),
shiny::tabPanel("Export",
- shiny::p("Original numeric matrices are exported without display normalization. Large matrix images are representative views."),
+ manir_guide(
+ "CSV and RDS preserve the original matrix values. PNG, PDF, SVG and interactive HTML preserve the displayed ordering and palette, but figure colors may be normalized or raster-sampled.",
+ "Plots from large datasets are representative previews, not complete value tables. Export the CSV or RDS alongside your figures and save the settings manifest to record clustering, color mapping, pair sampling and your R environment."),
+ shiny::uiOutput("difference_download_notice"),
shiny::downloadButton("download_first", "Matrix 1 CSV"),
shiny::downloadButton("download_second", "Matrix 2 CSV"),
shiny::downloadButton("download_rds", "Analysis matrices (RDS)"),
@@ -134,7 +217,7 @@ ui <- shiny::fluidPage(
shiny::downloadButton("download_svg", "Matrix 1 SVG"),
shiny::downloadButton("download_html", "Interactive HTML preview"),
shiny::downloadButton("download_combined_png", "Combined PNG"),
- shiny::downloadButton("download_difference", "Exact difference matrix CSV"),
+ shiny::uiOutput("download_difference_ui"),
shiny::downloadButton("download_settings", "Analysis settings (text)")
)
)
From 34c41cabd2692f241c2e8dcf094a24c0a433f66f Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:44:03 +0200
Subject: [PATCH 081/134] feat: add dynamic plot explanations, rank-gap
exploration, cluster overlap and safe difference guidance
---
server.R | 192 +++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 171 insertions(+), 21 deletions(-)
diff --git a/server.R b/server.R
index f7c1139..1a04c51 100644
--- a/server.R
+++ b/server.R
@@ -145,6 +145,7 @@ server <- function(input, output, session) {
loaded(analyze_input(example = FALSE))
})
shiny::observeEvent(input$load_example, {
+ shiny::updateCheckboxInput(session, "comparable_scales", value = FALSE)
loaded(analyze_input(example = TRUE))
})
output$download_example <- shiny::downloadHandler(
@@ -186,11 +187,23 @@ server <- function(input, output, session) {
list(first = subset_if_needed(d$second, ids),
second = subset_if_needed(d$first, ids))
})
- # Keep source matrices separate until a particular viewport is requested.
+ # A checkbox cannot make two fundamentally different measurement types
+ # subtractable. The bundled ANI/MALDI example is always protected.
+ can_difference <- shiny::reactive({
+ d <- data()
+ !is.null(d$second) && isTRUE(input$comparable_scales) &&
+ !identical(d$source, "synthetic example")
+ })
diff_matrix <- shiny::reactive({
- shiny::req(input$comparable_scales)
+ shiny::req(can_difference())
combined_first()
})
+ format_range <- function(m) {
+ values <- m[is.finite(m)]
+ if (!length(values)) return("no finite values")
+ sprintf("%s to %s", format(signif(min(values), 5L), trim = TRUE),
+ format(signif(max(values), 5L), trim = TRUE))
+ }
visible_indices <- function(n) {
ix <- if (isTRUE(input$zoom_enabled) && n > 300L) {
start <- max(1L, min(n, as.integer(input$zoom_start)))
@@ -228,8 +241,10 @@ server <- function(input, output, session) {
format(pair_data()$evaluated_pairs, big.mark = ","),
format(pair_data()$total_pairs, big.mark = ","),
pair_data()$seed)),
- if (!is.null(d$second) && !input$comparable_scales)
- shiny::p("The matrices may use different units. Pearson and Spearman correlations are reported; raw differences and error metrics are hidden.")
+ if (!is.null(d$second) && !can_difference())
+ shiny::p(if (identical(d$source, "synthetic example"))
+ "ANI percentages and MALDI spectral similarities have different units. Look at Pearson/Spearman association and rank gaps; direct differences are disabled for this example."
+ else "Direct differences are hidden until you confirm that the two matrices measure the same quantity in the same units.")
)
})
output$agreement <- shiny::renderTable({
@@ -238,14 +253,18 @@ server <- function(input, output, session) {
stats <- matrix_agreement(pp)
data.frame(
Measure = c("Valid isolate pairs", "Pearson r", "Spearman rho",
- if (input$comparable_scales) c("Mean absolute difference",
+ if (can_difference()) c("Mean absolute difference",
"Root mean squared difference")),
Value = c(stats$n, signif(stats$pearson, 5),
signif(stats$spearman, 5),
- if (input$comparable_scales)
+ if (can_difference())
c(signif(stats$mae, 5), signif(stats$rmse, 5)))
)
})
+ output$mantel_note <- shiny::renderUI({
+ shiny::p(class = "small-help",
+ "Optional Mantel test: click Run Mantel test in the sidebar. It permutes whole isolate labels to test matrix association. It does not demonstrate equal typing performance, and it cannot run on incomplete matrices or above 400 shared isolates.")
+ })
output$replicate_table <- shiny::renderTable({
d <- data()
shiny::req(d$metadata, nzchar(input$metadata_column))
@@ -258,8 +277,47 @@ server <- function(input, output, session) {
make_view <- function(prefix, source, name, combined = FALSE,
first_label = "Matrix 1", second_label = "Matrix 2",
is_difference = FALSE) {
+ if (!is_difference) {
+ output[[paste0(prefix, "_intro")]] <- shiny::renderUI({
+ m <- source()
+ d <- data()
+ if (combined) {
+ shiny::div(class = "section-note",
+ shiny::strong(sprintf("Upper triangle: %s (original range %s). ",
+ first_label, format_range(m$first))),
+ shiny::strong(sprintf("Lower triangle: %s (original range %s).",
+ second_label, format_range(m$second))),
+ shiny::p(class = "small-help",
+ "The common 0–1 color bar refers to separately normalized display colors, not a common measurement scale. A neutral diagonal separates the methods.")
+ )
+ } else {
+ kind <- if (prefix == "plot1") d$kind1 else d$kind2
+ shiny::p(class = "section-note",
+ sprintf("%s contains %d isolates. Original data range: %s. Input type: %s. Colors are rescaled for display; hover to see actual values.",
+ name, nrow(m), format_range(m), kind))
+ }
+ })
+ output[[paste0(prefix, "_legend")]] <- shiny::renderUI({
+ d <- data()
+ field <- input$metadata_column
+ key <- ma_metadata_palette(d$metadata, field)
+ if (is.null(key)) return(NULL)
+ shiny::div(class = "metadata-key",
+ shiny::span(class = "key-title",
+ sprintf("%s annotation:", field)),
+ lapply(names(key), function(group)
+ shiny::span(
+ shiny::span(class = "swatch",
+ style = paste0("background-color:", unname(key[[group]]))),
+ group
+ )),
+ shiny::span(class = "small-help",
+ "(color track above the heatmap; not a similarity scale)")
+ )
+ })
+ }
output[[paste0(prefix, "_ui")]] <- shiny::renderUI({
- if (is_difference) shiny::req(input$comparable_scales)
+ if (is_difference) shiny::req(can_difference())
m <- source()
size <- if (combined || is_difference) nrow(m$first) else nrow(m)
if (size <= 300L)
@@ -273,7 +331,7 @@ server <- function(input, output, session) {
)
})
output[[paste0(prefix, "_interactive")]] <- plotly::renderPlotly({
- if (is_difference) shiny::req(input$comparable_scales)
+ if (is_difference) shiny::req(can_difference())
m <- source()
size <- if (combined || is_difference) nrow(m$first) else nrow(m)
shiny::req(size <= 300L)
@@ -289,7 +347,7 @@ server <- function(input, output, session) {
metadata = meta, group_column = input$metadata_column)
})
output[[paste0(prefix, "_raster")]] <- shiny::renderPlot({
- if (is_difference) shiny::req(input$comparable_scales)
+ if (is_difference) shiny::req(can_difference())
m <- source()
size <- if (combined || is_difference) nrow(m$first) else nrow(m)
shiny::req(size > 300L)
@@ -326,7 +384,7 @@ server <- function(input, output, session) {
}, res = 110)
output[[paste0(prefix, "_cell")]] <- shiny::renderText({
shiny::req(input[[paste0(prefix, "_click")]])
- if (is_difference) shiny::req(input$comparable_scales)
+ if (is_difference) shiny::req(can_difference())
m <- source()
base <- if (combined || is_difference) m$first else m
ix <- visible_indices(nrow(base))
@@ -356,22 +414,69 @@ server <- function(input, output, session) {
make_view("difference", diff_matrix, "Matrix 1 minus matrix 2",
is_difference = TRUE)
+ output$pairwise_intro <- shiny::renderUI({
+ shiny::req(has_two())
+ d <- data()
+ p <- pair_data()
+ axis1 <- if (identical(d$source, "synthetic example"))
+ "fictional ANI-like percentage" else paste("matrix 1", d$kind1)
+ axis2 <- if (identical(d$source, "synthetic example"))
+ "fictional MALDI-like similarity (0–1)" else paste("matrix 2", d$kind2)
+ shiny::p(class = "section-note",
+ sprintf("%s distinct pairs with a value in both matrices, of %s possible pairs. X = %s; Y = %s.%s",
+ format(p$valid_pairs, big.mark = ","),
+ format(p$total_pairs, big.mark = ","),
+ axis1, axis2,
+ if (p$sampled)
+ sprintf(" Evaluated a reproducible sample of %s pairs (seed %d).",
+ format(p$evaluated_pairs, big.mark = ","), p$seed)
+ else " All pairs are included."))
+ })
output$scatter <- shiny::renderPlot({
shiny::req(has_two())
p <- pair_data()
d <- p$data
shiny::validate(shiny::need(nrow(d) >= 2L,
"At least two nonmissing sample pairs are needed."))
- graphics::plot(d$first, d$second, pch = 16L,
- col = grDevices::adjustcolor("#305f86", alpha.f = 0.27),
- cex = 0.5, xlab = "Matrix 1", ylab = "Matrix 2",
+ current <- data()
+ first_label <- if (identical(current$source, "synthetic example"))
+ "Illustrative ANI (%)" else paste("Matrix 1 (", current$kind1, ")", sep = "")
+ second_label <- if (identical(current$source, "synthetic example"))
+ "Illustrative MALDI similarity (0–1)" else
+ paste("Matrix 2 (", current$kind2, ")", sep = "")
+ graphics::plot(d$first, d$second, pch = 19L,
+ col = grDevices::adjustcolor("#305f86", alpha.f = 0.58),
+ cex = 0.9, xlab = first_label, ylab = second_label,
main = sprintf("Distinct isolate pairs (n = %s%s)",
- format(nrow(d), big.mark = ","),
- if (p$sampled) ", sampled" else ""))
- if (input$comparable_scales) graphics::abline(0, 1, lty = 2)
+ format(nrow(d), big.mark = ","),
+ if (p$sampled) ", sampled" else ""))
+ if (can_difference()) graphics::abline(0, 1, lty = 2, col = "#555555")
+ if (nrow(d) >= 3L) {
+ gap <- pair_rank_gaps(p, current$kind1, current$kind2, top = 5L)
+ key <- paste(d$sample_1, d$sample_2, sep = "|")
+ highlighted <- match(paste(gap$sample_1, gap$sample_2, sep = "|"), key)
+ graphics::points(d$first[highlighted], d$second[highlighted],
+ pch = 1L, cex = 1.65, lwd = 1.5, col = "#c45a35")
+ }
+ })
+ output$rank_gaps <- shiny::renderTable({
+ shiny::req(has_two())
+ current <- data()
+ gap <- pair_rank_gaps(pair_data(), current$kind1, current$kind2, top = 10L)
+ if (!nrow(gap)) return(NULL)
+ names(gap) <- c("Isolate 1", "Isolate 2", "Matrix 1", "Matrix 2", "Rank gap")
+ gap
+ }, digits = 4, rownames = FALSE)
+ output$absolute_discrepancies_intro <- shiny::renderUI({
+ if (!can_difference()) return(NULL)
+ shiny::tagList(
+ shiny::h4("Largest absolute differences"),
+ shiny::p(class = "small-help",
+ "These raw differences are available only because the two matrices have been marked as directly comparable. Sort by their magnitude for descriptive inspection, not a significance test.")
+ )
})
output$discrepancies <- shiny::renderTable({
- shiny::req(has_two(), input$comparable_scales)
+ shiny::req(has_two(), can_difference())
d <- pair_data()$data
d <- d[order(abs(d$difference), decreasing = TRUE), , drop = FALSE]
utils::head(d, 20L)
@@ -388,6 +493,13 @@ server <- function(input, output, session) {
kind_b = data()$kind2, k = input$cluster_k,
linkage = data()$linkage)
})
+ output$cluster_intro <- shiny::renderUI({
+ shiny::req(has_two())
+ d <- data()
+ shiny::p(class = "section-note",
+ sprintf("Clustering %d shared isolates independently in each matrix, using %d clusters and %s linkage. Change k in the sidebar to explore the stability of agreement.",
+ length(shared()$ids), input$cluster_k, d$linkage))
+ })
output$cluster_summary <- shiny::renderTable({
c <- concordance()
data.frame(measure = c("Adjusted Rand index",
@@ -396,13 +508,50 @@ server <- function(input, output, session) {
value = c(c$ari, c$adjusted_wallace_1_to_2,
c$adjusted_wallace_2_to_1))
})
+ output$cluster_overlap <- shiny::renderTable({
+ x <- concordance()$contingency
+ overlap <- as.data.frame.matrix(x)
+ names(overlap) <- paste("Matrix 2 cluster", colnames(x))
+ overlap <- data.frame("Matrix 1 cluster" = rownames(x), overlap,
+ check.names = FALSE, row.names = NULL)
+ overlap
+ }, rownames = FALSE)
output$cluster_table <- shiny::renderTable({
- utils::head(concordance()$assignments, 100L)
- })
+ assignment <- utils::head(concordance()$assignments, 100L)
+ names(assignment) <- c("Isolate", "Matrix 1 cluster", "Matrix 2 cluster")
+ assignment
+ }, rownames = FALSE)
output$metadata_preview <- shiny::renderTable({
shiny::req(data()$metadata)
utils::head(data()$metadata, 100L)
}, rownames = TRUE)
+ output$difference_explainer <- shiny::renderUI({
+ d <- data()
+ if (is.null(d$second))
+ return(shiny::p(class = "section-note",
+ "Upload a second matrix, or load the example, before comparing pairs."))
+ if (identical(d$source, "synthetic example"))
+ return(shiny::div(class = "reading-guide",
+ shiny::strong("Unavailable for the example"),
+ shiny::p("The fictional ANI measurements are percentage-like (about 95–100), while the fictional MALDI similarities range from 0 to 1. These are different measurement types. The application deliberately blocks direct subtraction for this example. Use the pairwise rank-gap table or the two combined heatmaps instead.")))
+ if (!can_difference())
+ return(shiny::p(class = "section-note",
+ "To enable a difference map, confirm in the sidebar that both matrices measure the same quantity in the same units and were processed compatibly."))
+ shiny::p(class = "section-note",
+ "Difference = matrix 1 minus matrix 2 in original units. Zero is the neutral midpoint; positive and negative values appear on opposite sides. Colors are scaled symmetrically around zero, so hover or export the exact matrix for numerical comparisons.")
+ })
+ output$download_difference_ui <- shiny::renderUI({
+ if (!can_difference()) return(NULL)
+ shiny::downloadButton("download_difference", "Exact difference matrix CSV")
+ })
+ output$difference_download_notice <- shiny::renderUI({
+ d <- data()
+ if (!can_difference())
+ shiny::p(class = "small-help",
+ if (identical(d$source, "synthetic example"))
+ "The difference export is disabled for the mixed-unit example."
+ else "The difference export appears after you confirm comparable measurements and load both matrices.")
+ })
output$metadata_note <- shiny::renderUI({
shiny::req(data()$metadata)
shiny::p("Showing up to 100 metadata rows. Choose a categorical metadata column in the sidebar to compare within-group and between-group matrix values.")
@@ -494,7 +643,7 @@ server <- function(input, output, session) {
output$download_difference <- shiny::downloadHandler(
filename = function() "MAniR_difference_matrix.csv",
content = function(file) {
- shiny::req(input$comparable_scales)
+ shiny::req(can_difference())
m <- combined_first()
# Full difference matrices are intentionally materialized only when
# explicitly requested for export.
@@ -515,7 +664,8 @@ server <- function(input, output, session) {
paste("Linkage:", d$linkage),
paste("Color palette:", input$palette),
paste("Log display scaling:", input$log_scale),
- paste("Comparable units:", input$comparable_scales),
+ paste("Comparable units selected:", input$comparable_scales),
+ paste("Difference analysis permitted:", can_difference()),
paste("Maximum analyzed pairs:", input$max_pairs),
paste("Pair sampling seed:", 1L),
paste("Mantel seed:", 1L),
From 6e2446c9681de16239c7139ec39cb9b43463fb0c Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:44:51 +0200
Subject: [PATCH 082/134] fix: reset example controls to the measurement types
and clustering actually used
---
server.R | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/server.R b/server.R
index 1a04c51..97d3545 100644
--- a/server.R
+++ b/server.R
@@ -45,11 +45,15 @@ server <- function(input, output, session) {
kind1 <- "similarity"
kind2 <- "similarity"
match_mode <- "strict"
+ cluster_requested <- TRUE
+ linkage <- "complete"
shiny::incProgress(0.40)
} else {
kind1 <- input$kind1
kind2 <- input$kind2
match_mode <- input$match_mode
+ cluster_requested <- isTRUE(input$cluster)
+ linkage <- input$linkage
f <- input$first_file
wb1 <- if (is_excel(f)) openxlsx::loadWorkbook(f$datapath) else NULL
a <- read_matrix_input(f$datapath,
@@ -108,8 +112,8 @@ server <- function(input, output, session) {
if (!is.null(explicit))
return(c(intersect(explicit, colnames(m)),
setdiff(colnames(m), explicit)))
- matrix_order(m, kind, cluster = isTRUE(input$cluster),
- linkage = input$linkage)$ids
+ matrix_order(m, kind, cluster = cluster_requested,
+ linkage = linkage)$ids
}
order1 <- order_for(a, kind1)
shiny::incProgress(0.30)
@@ -128,11 +132,11 @@ server <- function(input, output, session) {
order1 = order1, order2 = order2,
source = if (example) "synthetic example" else "uploaded files",
matching = match_mode,
- clustering_skipped = isTRUE(input$cluster) &&
+ clustering_skipped = cluster_requested &&
is.null(explicit) &&
(nrow(a) > 2000L || (!is.null(b) && nrow(b) > 2000L)),
kind1 = kind1, kind2 = kind2,
- linkage = input$linkage)
+ linkage = linkage)
}, error = function(e) {
shiny::showNotification(conditionMessage(e), type = "error",
duration = NULL)
@@ -146,6 +150,12 @@ server <- function(input, output, session) {
})
shiny::observeEvent(input$load_example, {
shiny::updateCheckboxInput(session, "comparable_scales", value = FALSE)
+ shiny::updateSelectInput(session, "kind1", selected = "similarity")
+ shiny::updateSelectInput(session, "kind2", selected = "similarity")
+ shiny::updateRadioButtons(session, "match_mode", selected = "strict")
+ shiny::updateCheckboxInput(session, "cluster", value = TRUE)
+ shiny::updateSelectInput(session, "linkage", selected = "complete")
+ shiny::updateNumericInput(session, "cluster_k", value = 3)
loaded(analyze_input(example = TRUE))
})
output$download_example <- shiny::downloadHandler(
From 6d076919a979d950a32cb00adb207e999d9c6c75 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:45:25 +0200
Subject: [PATCH 083/134] test: cover interpretation UI, scale-independent rank
gaps and nonoverlapping metadata track
---
tests/run_tests.R | 43 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 46dd469..9d94772 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -255,4 +255,47 @@ test("downloadable XLSX example round-trips all matrices and metadata", {
stopifnot(grepl("SYNTHETIC", guide$Notes[1L]))
})
+
+test("rank gaps compare pair order without subtracting incompatible units", {
+ example <- manir_load_example()
+ pair <- paired_values(example$first, example$second)
+ top <- pair_rank_gaps(pair, "similarity", "similarity", top = 10L)
+ stopifnot(nrow(top) == 10L, all(is.finite(top$rank_gap)),
+ all(top$rank_gap >= 0 & top$rank_gap <= 1),
+ all(diff(top$rank_gap) <= 0))
+ # Distances are reversed before comparing pair ranks, so negating all
+ # second-matrix scores and marking them as distance changes no rank gaps.
+ reversed <- pair
+ reversed$data$second <- -reversed$data$second
+ alt <- pair_rank_gaps(reversed, "similarity", "distance", top = 10L)
+ stopifnot(isTRUE(all.equal(top$rank_gap, alt$rank_gap)))
+ expect_error(pair_rank_gaps(pair, "invalid", "similarity"), "Unrecognized")
+})
+
+test("metadata colors keep their meaning across both matrix orders", {
+ example <- manir_load_example()
+ key <- ma_metadata_palette(example$metadata, "group")
+ reordered <- example$metadata[rev(rownames(example$metadata)), , drop = FALSE]
+ stopifnot(length(key) == 3L,
+ identical(key, ma_metadata_palette(reordered, "group")),
+ is.null(ma_metadata_palette(example$metadata, "missing")))
+ # The strip must live above the plot, not in the y-axis label margin.
+ p <- ma_interactive(example$first, metadata = example$metadata,
+ group_column = "group")
+ stopifnot(length(p$x$layout$shapes) == nrow(example$first),
+ all(vapply(p$x$layout$shapes,
+ function(x) identical(x$yref, "paper") && x$y0 > 1,
+ logical(1L))))
+})
+
+test("every result tab includes contextual interpretation guidance", {
+ source("ui.R", local = TRUE)
+ view <- htmltools::renderTags(ui)$html
+ stopifnot(grepl("How to read this", view, fixed = TRUE),
+ grepl("Adjusted Wallace", view, fixed = TRUE),
+ grepl("rank differences", view, fixed = TRUE),
+ grepl("different measurement", view, fixed = TRUE),
+ grepl("metadata", view, fixed = TRUE))
+})
+
cat("All MAniR regression tests passed.\n")
From b98d0d9aeb9f1c25cfbb6518a75a7d922ae0b212 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:45:27 +0200
Subject: [PATCH 084/134] test: block mixed-unit difference and verify
compatible uploaded pair handling
---
tests/test_shiny.R | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/tests/test_shiny.R b/tests/test_shiny.R
index 0b275b1..af9e647 100644
--- a/tests/test_shiny.R
+++ b/tests/test_shiny.R
@@ -46,6 +46,10 @@ shiny::testServer(server, {
nrow(demo$metadata) == 12L,
identical(demo$matching, "strict")
)
+ # Even when a checkbox is set, mixed-unit teaching data must not be
+ # subtracted or exported as a misleading difference matrix.
+ session$setInputs(comparable_scales = TRUE)
+ stopifnot(!can_difference())
# An ordinary upload after the example must replace it without carrying
# across the example metadata or second matrix.
session$setInputs(first_file = file_info, visualize = 1)
@@ -53,9 +57,14 @@ shiny::testServer(server, {
stopifnot(identical(own$source, "uploaded files"),
nrow(own$first) == 4L, is.null(own$second),
is.null(own$metadata))
+ session$setInputs(second_file = file_info, comparable_scales = TRUE,
+ visualize = 2)
+ stopifnot(can_difference(),
+ nrow(shared()$first) == 4L)
session$setInputs(load_example = 2)
stopifnot(identical(loaded()$source, "synthetic example"),
- nrow(loaded()$second) == 12L)
+ nrow(loaded()$second) == 12L, !can_difference(),
+ identical(loaded()$linkage, "complete"))
})
cat("Shiny upload and example reactive smoke tests passed.\n")
From 4105ba4de472e19f0acf2444b80bc87690078c23 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:46:07 +0200
Subject: [PATCH 085/134] docs: provide research-oriented guide to every MAniR
visualization and statistic
---
docs/INTERPRETING_RESULTS.md | 160 +++++++++++++++++++++++++++++++++++
1 file changed, 160 insertions(+)
create mode 100644 docs/INTERPRETING_RESULTS.md
diff --git a/docs/INTERPRETING_RESULTS.md b/docs/INTERPRETING_RESULTS.md
new file mode 100644
index 0000000..6008fda
--- /dev/null
+++ b/docs/INTERPRETING_RESULTS.md
@@ -0,0 +1,160 @@
+# Reading MAniR results
+
+MAniR compares **pairwise measurements for the same isolates**. It does not
+calculate ANI from genomes or align raw MALDI-TOF peaks. Start with two
+precomputed, named, square, symmetric matrices. For example, one could contain
+genomic ANI percentages and another MALDI-TOF spectral similarities. Each row
+and column refers to an isolate, and the same cell in both matrices must refer
+to the same pair. The bundled teaching data are fictional.
+
+## Choose the right input type
+
+- **Similarity:** larger values indicate greater similarity (for example, ANI
+ or a MALDI spectral similarity score). The absolute range depends on the
+ method, so 99% ANI and 0.99 MALDI similarity are not interchangeable.
+- **Correlation:** values lie between -1 and +1. A more positive value
+ indicates a more positive relationship; -1 is strong inverse correlation.
+- **Distance:** smaller values mean more similar samples. MAniR reverses
+ distance ordering internally when constructing a clustering dissimilarity or
+ comparing ranks, but does **not** change the original uploaded values.
+
+"Unspecified" treats larger values as more similar for clustering. Supply the
+correct type if you want the scientific meaning of the ordering and rank
+comparison to be accurate. Neither the palette nor a logarithmic
+transformation changes the input type.
+
+## Reading single heatmaps
+
+Each cell is one row-isolate versus column-isolate comparison. A symmetric
+matrix repeats the same number on both sides of the diagonal. Clustering
+reorders the rows and columns **together**, placing similar patterns adjacent.
+A cluster-looking block in the picture is a visual clue, not a validated taxon.
+
+The color legend is **relative display color (0–1)**, not the input units.
+MAniR maps the range of each displayed matrix to the palette. With the
+default diverging palette, red represents the low end and blue the high end
+of that display range. Hover over a cell for its exact input value. For a
+distance matrix, remember that low (red) values indicate closer samples,
+the opposite of the biological interpretation of a similarity matrix.
+
+If you choose a categorical metadata field, colored rectangles appear in
+a narrow track above the interactive heatmap. A separate legend above the
+plot explains which group has each color; the colors are stable when you
+switch the matrix ordering. Metadata are additional isolate attributes,
+not measurements of pairwise agreement. Gray indicates missing metadata.
+Large raster views show a narrow side track rather than putting a legend
+over the plot.
+
+## The two combined views
+
+A split matrix uses **matrix 1 above the diagonal** and **matrix 2 below
+the diagonal**, with the same isolates along both axes. Combined (order 1)
+uses clustering from matrix 1; Combined (order 2) puts matrix 2 above the
+diagonal and clusters using matrix 2 instead. Compare the apparent blocks
+and which samples move when you change the ordering.
+
+MAniR normalizes the **two triangles separately**. The same blue shade
+can therefore represent ANI of 99% and MALDI similarity of 0.9; this
+is not equal biological similarity. The neutral diagonal in a split view
+is a visual separator, not a measured 0.5. Hover to see which matrix
+contributed a cell and its original value.
+
+## Pairwise comparison and rank gaps
+
+Each scatterplot dot shows one unique unordered pair of isolates.
+For n isolates, there are n(n-1)/2 pairs, excluding the diagonal and the
+mirrored half. With the 12-isolate example, that is 66 pairs. Matrix 1
+supplies the X value and matrix 2 the Y value. An upward pattern suggests
+positive association between the two types of pairwise measurements.
+
+Two useful summaries are Pearson's r for a linear relationship and
+Spearman's rho for the consistency of ranks. Values lie between -1 and +1.
+They do not establish that two typing methods have equal resolution,
+accuracy or biological validity. Since each isolate is reused in many
+pairs, those dots are **not independent observations**.
+
+The rank-gap table is useful when units differ. It ranks pairs by each
+measurement, placing more similar samples higher in the ranking. For
+distance matrices, the direction is reversed before ranking. The absolute
+difference between the two percentile ranks is shown as a **rank gap**:
+a larger gap means the pair occupies a more different relative position
+in the two matrices. Orange outlines in the plot mark up to five of the
+largest rank gaps. This is an exploratory list, **not** an outlier test or
+a measure of statistical significance.
+
+The bundled example intentionally includes two unusual pairs:
+ISO_02 / ISO_03 have fictional ANI 99.655 and MALDI similarity 0.52;
+ISO_04 / ISO_05 have fictional ANI 97.22 and MALDI similarity 0.88.
+They illustrate the need to inspect individual discordant pairs.
+Do not interpret those numbers as measured bacterial biology.
+
+The app evaluates up to the selected maximum number of distinct pairs.
+For larger datasets, it uses a reproducible sample and reports the
+number of evaluated pairs. The rank gaps and displayed summary then
+describe **that sample**, not necessarily every pair in the input.
+
+## Cluster agreement
+
+Choose the number of clusters k in the sidebar. MAniR independently
+clusters each matrix using its specified type, then cuts both trees into
+k groups. The linkage method and choice of k may change the result.
+
+Cluster labels such as "1" and "2" are arbitrary. Two labels with the
+same number are not necessarily the same biological group. Use the
+cluster-overlap table to see which groups contain the same isolates.
+The adjusted Rand index (ARI) is invariant to renaming clusters:
+1 indicates the same partition, around 0 corresponds to chance-level
+agreement under its adjustment model, and negative values can indicate
+less agreement than that model expects.
+
+Adjusted Wallace is directional. Matrix 1 → matrix 2 asks how well
+pairs grouped together by the first matrix remain grouped by the second,
+after correcting for the expected chance grouping. Reversing the
+direction changes which matrix supplies the original pair groupings;
+the values can differ. An adjusted Wallace value is **not the probability
+that the first typing method is biologically correct**.
+
+Both metrics compare the selected cluster assignments, not the full
+distribution of matrix values or independently confirmed biological labels.
+
+## Optional Mantel test
+
+The Mantel test assesses matrix association while retaining the
+dependence between cells by permuting **whole isolate labels** in one
+matrix. Its permutation p-value is not obtained by treating every dot in
+the scatterplot as an independent observation. The app uses a two-sided
+permutation test and requires complete matrices with no more than 400
+shared isolates. A small p-value is evidence against the chosen
+no-association permutation null, **not** evidence that the methods are
+equivalent, diagnostic or causal. Save the number of permutations and
+random seed with the results.
+
+## Difference plots: only directly comparable measurements
+
+A difference is original matrix 1 minus original matrix 2 for the same
+pair. Use this only when both matrices measure the **same quantity**,
+in the **same units**, with compatible preprocessing. Comparing ANI
+from two sequencing runs can qualify; ANI percentages versus MALDI
+spectral scores do not. The example deliberately disables this feature
+even if the comparable-measurements box is checked.
+
+In a valid difference heatmap, zero maps to the neutral midpoint and
+positive/negative differences map to opposite sides of the diverging
+palette. The colors are symmetric around zero but rescaled for display:
+hover or export the exact difference matrix to interpret magnitudes.
+A difference by itself does not establish which method is correct.
+
+## Metadata and reproducibility
+
+Metadata categories (group, source, batch, etc.) are descriptive.
+Within-group versus between-group pair summaries may suggest something
+to investigate, but they are not independent-replicate statistical tests
+and do not account for experimental confounding. If apparent biological
+groups follow batches, consider an appropriate follow-up study.
+
+CSV and RDS exports contain original numerical values. Figures may use
+normalized colors and representative downsampling for very large
+matrices. Keep the original matrices and metadata, the settings manifest,
+the result tables and the actual package versions with published figures.
+
+[Back to README](../README.md)
From a8376d7838cf279c9edfc1c061bcd59cbafcfea6 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:46:10 +0200
Subject: [PATCH 086/134] docs: link the in-app interpretation guidance to a
complete reference
---
README.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/README.md b/README.md
index 874bb91..d79f7b9 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,11 @@ MAniR is an R Shiny application for investigating pairwise relationships between
This development branch is **MAniR 3.0 prerelease**. The historical application remains available in the repository's Git history and on `main` until the upgrade is reviewed. Do not cite an unverified performance improvement: the benchmark scripts need to be run on specified hardware.
+For help understanding the plots, correlations, directional cluster agreement,
+rank gaps, metadata tracks and the restrictions on difference maps, see
+[Reading MAniR results](docs/INTERPRETING_RESULTS.md). The application also
+displays a short explanation directly on each analysis tab.
+
## Installation
Install R 4.2 or later. The upload limit defaults to 1,024 MB and can be configured with the `MANIR_MAX_UPLOAD_MB` environment variable. Plan available RAM for at least the uploaded matrices plus working data, especially for two 10,000-isolate matrices. For those larger projects, RDS or CSV is preferable to Excel. In the repository directory, run:
From 899c9b6999cc464d33e522979e146d230bb95b62 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:47:53 +0200
Subject: [PATCH 087/134] test: inspect metadata strip after Plotly constructs
the full widget layout
---
tests/run_tests.R | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 9d94772..b456462 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -280,8 +280,8 @@ test("metadata colors keep their meaning across both matrix orders", {
identical(key, ma_metadata_palette(reordered, "group")),
is.null(ma_metadata_palette(example$metadata, "missing")))
# The strip must live above the plot, not in the y-axis label margin.
- p <- ma_interactive(example$first, metadata = example$metadata,
- group_column = "group")
+ p <- plotly::plotly_build(ma_interactive(
+ example$first, metadata = example$metadata, group_column = "group"))
stopifnot(length(p$x$layout$shapes) == nrow(example$first),
all(vapply(p$x$layout$shapes,
function(x) identical(x$yref, "paper") && x$y0 > 1,
From fe2cf0b6d5d8faeb6ef2fdcb2951e55f5f85fb9d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:50:00 +0200
Subject: [PATCH 088/134] fix: omit uninterpretable raw differences from
cross-method CSV exports
---
R/matrix_analysis.R | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/R/matrix_analysis.R b/R/matrix_analysis.R
index 83e8183..351c8e4 100644
--- a/R/matrix_analysis.R
+++ b/R/matrix_analysis.R
@@ -216,3 +216,12 @@ pair_rank_gaps <- function(pairs, kind1 = "similarity",
utils::head(d[c("sample_1", "sample_2", "first", "second", "rank_gap")],
as.integer(top))
}
+
+# Keep the exported table honest when the source measurements use different
+# units. The internal pairwise values still include the difference for
+# calculations that are explicitly enabled only for compatible measurements.
+pair_export_data <- function(pairs, comparable = FALSE) {
+ result <- pairs$data
+ if (!isTRUE(comparable)) result$difference <- NULL
+ result
+}
From 230a4e705ad84bb30e3f309e33f7904e7b02c789 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:50:04 +0200
Subject: [PATCH 089/134] feat: distinguish active dataset settings from
changed sidebar controls
---
server.R | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/server.R b/server.R
index 97d3545..219d59b 100644
--- a/server.R
+++ b/server.R
@@ -164,6 +164,23 @@ server <- function(input, output, session) {
)
data <- shiny::reactive({ shiny::req(loaded()); loaded() })
+ output$active_analysis <- shiny::renderUI({
+ d <- data()
+ if (identical(d$source, "synthetic example")) {
+ return(shiny::p(class = "small-help",
+ "Currently displayed: synthetic ANI-like and MALDI-like similarity matrices. This example always uses similarity inputs, even if a dropdown below is changed. Reload the example to reset the controls."))
+ }
+ changed <- (!is.null(input$kind1) && !identical(input$kind1, d$kind1)) ||
+ (!is.null(input$kind2) && !identical(input$kind2, d$kind2)) ||
+ (!is.null(input$linkage) && !identical(input$linkage, d$linkage))
+ shiny::p(class = "small-help",
+ sprintf("Currently displayed: %s (matrix 1), %s (matrix 2); %s linkage.%s",
+ d$kind1, if (is.null(d$second)) "not loaded" else d$kind2,
+ d$linkage,
+ if (changed)
+ " You have changed analysis settings. Click Load and analyze to apply them."
+ else ""))
+ })
has_two <- shiny::reactive(!is.null(data()$second))
subset_if_needed <- function(m, ids) {
if (identical(colnames(m), ids) && identical(rownames(m), ids))
@@ -595,7 +612,8 @@ server <- function(input, output, session) {
filename = function() "MAniR_pairwise_values.csv",
content = function(file) {
shiny::req(has_two())
- utils::write.csv(pair_data()$data, file, row.names = FALSE)
+ values <- pair_export_data(pair_data(), comparable = can_difference())
+ utils::write.csv(values, file, row.names = FALSE)
})
output$download_clusters <- shiny::downloadHandler(
filename = function() "MAniR_clusters.csv",
From 48917a97f43f88050cb097a4177603797984549d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:50:06 +0200
Subject: [PATCH 090/134] docs(ui): clarify when type, matching and linkage
changes take effect
---
ui.R | 3 +++
1 file changed, 3 insertions(+)
diff --git a/ui.R b/ui.R
index bec18ef..af5df99 100644
--- a/ui.R
+++ b/ui.R
@@ -55,6 +55,7 @@ ui <- shiny::fluidPage(
shiny::p(class = "small-help",
"Synthetic data for demonstration, not real biological measurements.")
),
+ shiny::uiOutput("active_analysis"),
shiny::hr(),
shiny::h4("Upload your data"),
shiny::p(class = "small-help",
@@ -85,6 +86,8 @@ ui <- shiny::fluidPage(
"Shared isolates only" = "intersection"),
selected = "strict"),
shiny::actionButton("visualize", "Load and analyze", class = "btn-primary"),
+ shiny::p(class = "small-help",
+ "Matrix type, linkage and sample matching are applied when you load a dataset. Palette, annotation track and zoom controls can change the current view without reloading."),
shiny::hr(),
shiny::h4("Visualization"),
shiny::checkboxInput("cluster", "Cluster samples", value = TRUE),
From 0ebef1a92a23316c49099339a0f128098be348a1 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:50:09 +0200
Subject: [PATCH 091/134] test: ensure mixed-unit pair exports cannot contain
raw difference columns
---
tests/run_tests.R | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index b456462..0195ecd 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -256,6 +256,16 @@ test("downloadable XLSX example round-trips all matrices and metadata", {
})
+test("pair export hides raw differences between incompatible measurement scales", {
+ example <- manir_load_example()
+ pairs <- paired_values(example$first, example$second)
+ separate <- pair_export_data(pairs)
+ comparable <- pair_export_data(pairs, comparable = TRUE)
+ stopifnot(!"difference" %in% names(separate),
+ "difference" %in% names(comparable),
+ nrow(separate) == 66L)
+})
+
test("rank gaps compare pair order without subtracting incompatible units", {
example <- manir_load_example()
pair <- paired_values(example$first, example$second)
From 27c442c388b4957b7f9438ecd13f739e7010ffd2 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:50:12 +0200
Subject: [PATCH 092/134] docs: explain why pairwise CSV omits raw differences
on mixed-unit data
---
docs/INTERPRETING_RESULTS.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/INTERPRETING_RESULTS.md b/docs/INTERPRETING_RESULTS.md
index 6008fda..739c7ac 100644
--- a/docs/INTERPRETING_RESULTS.md
+++ b/docs/INTERPRETING_RESULTS.md
@@ -88,6 +88,10 @@ ISO_04 / ISO_05 have fictional ANI 97.22 and MALDI similarity 0.88.
They illustrate the need to inspect individual discordant pairs.
Do not interpret those numbers as measured bacterial biology.
+The downloaded pairwise CSV omits the raw difference column unless you have
+confirmed the matrices are directly comparable. The synthetic example always
+omits it because subtracting its measurements would be misleading.
+
The app evaluates up to the selected maximum number of distinct pairs.
For larger datasets, it uses a reproducible sample and reports the
number of evaluated pairs. The rank gaps and displayed summary then
From c02dd204045cbe47d2a07be381270a7a76e0147f Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:56:58 +0200
Subject: [PATCH 093/134] style: introduce fixed-height split workspace and
accessible persistent analysis toolbar
---
www/workspace.css | 169 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 169 insertions(+)
create mode 100644 www/workspace.css
diff --git a/www/workspace.css b/www/workspace.css
new file mode 100644
index 0000000..b541375
--- /dev/null
+++ b/www/workspace.css
@@ -0,0 +1,169 @@
+
+/* Desktop analysis workspace: independent control and result scrolling.
+ * Mobile and short windows use the normal document flow instead. */
+:root {
+ --manir-ink: #203c50;
+ --manir-muted: #657b8e;
+ --manir-border: #dbe6ec;
+ --manir-brand: #176a86;
+ --manir-bg: #f2f6f8;
+}
+body { background: var(--manir-bg); color: var(--manir-ink); }
+.container-fluid { padding-left: 18px; padding-right: 18px; }
+.app-header {
+ display: flex; align-items: center; gap: 16px;
+ min-height: 73px; padding: 12px 3px 14px; flex-wrap: wrap;
+}
+.app-header h2 { font-size: 26px; font-weight: 650; letter-spacing: -.025em; margin: 0; }
+.app-header .app-description { font-size: 13px; color: var(--manir-muted); }
+.workspace { min-height: 0; flex: 1 1 auto; }
+.workspace > .row { height: 100%; display: flex; flex-wrap: nowrap; }
+.workspace > .row > .col-sm-3, .workspace > .row > .col-sm-9 {
+ min-width: 0; display: flex; flex-direction: column; float: none;
+}
+.workspace > .row > .col-sm-3 { width: 26%; }
+.workspace > .row > .col-sm-9 { width: 74%; }
+.workspace .well {
+ min-height: 0; flex: 1 1 auto;
+ padding: 0; margin-bottom: 0; background: white;
+ border: 1px solid var(--manir-border);
+ box-shadow: none; border-radius: 10px;
+ overflow: hidden;
+}
+.control-sidebar {
+ display: flex; flex-direction: column; height: 100%; min-height: 0;
+}
+.sidebar-scroll {
+ flex: 1 1 auto; min-height: 0; overflow-y: auto;
+ overscroll-behavior: contain; padding: 14px 16px 24px;
+ scrollbar-color: #b7cbd5 transparent;
+}
+.sidebar-scroll h4 { margin: 4px 0 8px; font-size: 15px; font-weight: 650; }
+.example-quick {
+ border-radius: 8px; padding: 12px; background: #eef6f9;
+ margin-bottom: 13px; border: 1px solid #d4e6ef;
+}
+.example-quick .btn-primary { background: var(--manir-brand); border-color: var(--manir-brand); }
+.example-quick p { font-size: 12px; line-height: 1.45; margin: 4px 0 10px; color: #4e6679; }
+.example-quick details { margin: 8px 0 0; font-size: 12px; }
+.example-quick details summary { cursor: pointer; color: #315770; }
+.control-group {
+ border: 1px solid var(--manir-border); border-radius: 8px;
+ background: #fff; margin-bottom: 10px;
+}
+.control-group > summary {
+ list-style-position: inside; padding: 11px 12px;
+ font-weight: 650; color: #29485d; cursor: pointer;
+ background: #f8fbfc; border-radius: 8px;
+}
+.control-group[open] > summary { border-bottom: 1px solid var(--manir-border); border-radius: 8px 8px 0 0; }
+.control-group > .group-body { padding: 12px 12px 2px; }
+.group-body .form-group { margin-bottom: 12px; }
+.group-body label { font-size: 12px; }
+.group-body .small-help { font-size: 11px; margin: 4px 0 12px; }
+.sidebar-footer {
+ background: #fff; flex: 0 0 auto;
+ padding: 11px 16px 12px; border-top: 1px solid var(--manir-border);
+ box-shadow: 0 -3px 14px rgba(19, 54, 74, .035);
+}
+.sidebar-footer .btn { display: block; width: 100%; font-weight: 650; margin: 0; }
+.sidebar-footer .load-status { display: block; color: var(--manir-muted); font-size: 11px; margin-top: 6px; }
+.workspace .main-panel {
+ flex: 1 1 auto; min-height: 0; overflow: hidden;
+ display: flex; flex-direction: column;
+ padding: 0; border: 1px solid var(--manir-border);
+ border-radius: 10px; box-shadow: none; background: #fff;
+}
+.results-toolbar {
+ flex: 0 0 auto; padding: 10px 17px 7px;
+ display: flex; align-items: flex-start; flex-wrap: wrap;
+ column-gap: 18px; row-gap: 6px; border-bottom: 1px solid var(--manir-border);
+ background: #fcfefe;
+}
+.toolbar-field { min-width: 120px; max-width: 220px; }
+.toolbar-field .form-group, .toolbar-field .shiny-input-container { margin-bottom: 0; width: 100%; }
+.toolbar-field label { font-size: 11px; font-weight: 650; margin-bottom: 3px; color: #466376; }
+.toolbar-field .form-control { height: 31px; font-size: 12px; padding: 3px 8px; }
+.toolbar-field.control-checkbox { align-self: center; min-width: auto; padding-top: 11px; }
+.toolbar-field.control-checkbox label { font-weight: 500; font-size: 12px; white-space: nowrap; }
+.toolbar-field.control-checkbox .checkbox { margin: 0; }
+.toolbar-more { margin-top: 5px; flex: 0 0 auto; align-self: center; font-size: 12px; }
+.toolbar-more summary { cursor: pointer; color: var(--manir-brand); padding: 6px; }
+.toolbar-more[open] { width: 100%; }
+.toolbar-extra { display: flex; flex-wrap: wrap; align-items: end; gap: 6px 16px; padding: 8px 0; }
+.toolbar-extra .shiny-input-container { width: 138px; margin-bottom: 0; }
+.toolbar-status {
+ flex: 1 1 200px; margin-left: auto; align-self: center;
+ text-align: right; max-width: 400px;
+ color: var(--manir-muted);
+}
+.toolbar-status p { font-size: 11px; line-height: 1.3; margin: 0; }
+.toolbar-field.contextual { align-self: center; }
+.toolbar-field.contextual .shiny-input-container { width: 94px; }
+.results-shell { min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; }
+.results-shell > .tabbable { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
+.results-shell .nav-tabs {
+ flex: 0 0 auto; padding: 0 15px;
+ display: flex; flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
+ border-bottom: 1px solid var(--manir-border); scrollbar-width: thin;
+}
+.results-shell .nav-tabs > li { float: none; flex: 0 0 auto; }
+.results-shell .nav-tabs > li > a { margin-right: 1px; padding: 11px 12px; font-size: 12px; white-space: nowrap; }
+.results-shell .tab-content {
+ flex: 1 1 auto; min-height: 0; overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 8px 20px 24px;
+}
+.results-shell .tab-pane { max-width: 100%; }
+.results-shell .reading-guide {
+ background: #f3f8fb; border: 1px solid #dae7ee;
+ border-left: 3px solid #52859e; border-radius: 5px;
+ padding: 0; margin: 5px 0 12px;
+}
+.results-shell .reading-guide > summary {
+ cursor: pointer; list-style-position: inside; color: #385c72;
+ font-size: 12px; padding: 8px 11px; user-select: none;
+}
+.results-shell .reading-guide > summary .guide-preview { font-weight: normal; color: var(--manir-muted); margin-left: 7px; }
+.results-shell .reading-guide > .guide-body { padding: 3px 13px 12px; font-size: 12px; line-height: 1.5; max-width: 100ch; }
+.results-shell .reading-guide > .guide-body p { margin: 2px 0 8px; }
+.results-shell .section-note { margin: 8px 0; font-size: 12px; }
+.results-shell .metadata-key { margin: 5px 0; }
+.results-shell .results-table { margin: 10px 0; }
+.results-shell h4 { font-size: 16px; margin: 14px 0 9px; }
+.plot-frame { height: clamp(400px, calc(100dvh - 310px), 900px); width: 100%; min-width: 0; }
+.plot-frame .shiny-plot-output, .plot-frame .plotly {
+ height: 100% !important; min-height: 0; width: 100%;
+}
+.results-shell .tab-content .btn { margin: 5px 8px 6px 0; }
+@media (min-width: 992px) and (min-height: 580px) {
+ html, body { height: 100%; overflow: hidden; }
+ body > .container-fluid {
+ height: 100dvh; min-height: 0; display: flex; flex-direction: column; overflow: hidden;
+ }
+}
+@media (max-width: 991px), (max-height: 579px) {
+ html, body { height: auto; overflow: auto; }
+ .app-header { min-height: 54px; }
+ .workspace > .row { display: block; height: auto; }
+ .workspace > .row > .col-sm-3, .workspace > .row > .col-sm-9 {
+ display: block; width: 100%; float: none; padding-left: 0; padding-right: 0;
+ }
+ .workspace .well { margin-bottom: 12px; overflow: visible; height: auto; }
+ .control-sidebar { height: auto; }
+ .sidebar-scroll { overflow: visible; }
+ .sidebar-footer { position: sticky; bottom: 0; z-index: 40; border-radius: 0 0 8px 8px; }
+ .workspace .main-panel { overflow: visible; }
+ .results-shell > .tabbable { display: block; }
+ .results-shell .tab-content { overflow: visible; }
+ .plot-frame { height: min(68vh, 700px); min-height: 380px; }
+ .toolbar-status { text-align: left; max-width: none; }
+}
+@media (max-width: 600px) {
+ .container-fluid { padding-left: 10px; padding-right: 10px; }
+ .app-description { display: none; }
+ .results-toolbar { padding: 9px; gap: 8px; }
+ .toolbar-status { flex-basis: 100%; margin: 0; }
+ .results-shell .tab-content { padding: 8px 10px 18px; }
+ .results-shell .reading-guide .guide-preview { display: none; }
+}
From 565a494bd8986af8dc092d77adda1868b978080c Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:57:39 +0200
Subject: [PATCH 094/134] ui: keep results visible with independent scrolling,
compact sidebar and persistent live controls
---
ui.R | 244 ++++++++++++++++++++++++++++++++++++-----------------------
1 file changed, 149 insertions(+), 95 deletions(-)
diff --git a/ui.R b/ui.R
index af5df99..d14514f 100644
--- a/ui.R
+++ b/ui.R
@@ -1,13 +1,16 @@
# MAniR: matrix comparison and scalable visualization.
manir_guide <- function(lead, details = NULL) {
- shiny::div(class = "reading-guide",
- shiny::strong("How to read this"),
- shiny::p(lead),
- if (!is.null(details))
- shiny::tags$details(
- shiny::tags$summary("More explanation"),
- shiny::p(details)
- )
+ # Help stays one short line until opened, leaving the visualization visible.
+ preview <- strsplit(lead, "[.!?]")[[1L]][1L]
+ if (nchar(preview) > 108L) preview <- paste0(substr(preview, 1L, 105L), "...")
+ shiny::tags$details(class = "reading-guide",
+ shiny::tags$summary(shiny::strong("How to read this"),
+ shiny::span(class = "guide-preview", preview)),
+ shiny::div(class = "guide-body",
+ shiny::p(lead),
+ if (!is.null(details))
+ shiny::tagList(shiny::strong("Further interpretation"), shiny::p(details))
+ )
)
}
ui <- shiny::fluidPage(
@@ -39,98 +42,145 @@ ui <- shiny::fluidPage(
.section-note { margin: 13px 0 8px; color: #3b5668; }
.results-table { margin: 12px 0 20px; }
.main-panel .btn { margin: 7px 7px 7px 0; }
- "))
+ ")),
+ shiny::tags$link(rel = "stylesheet", type = "text/css",
+ href = "workspace.css")
+ ),
+ shiny::div(class = "app-header",
+ shiny::h2("MAniR"),
+ shiny::span(class = "app-description",
+ "Compare pairwise similarities, correlations and distances")
),
- shiny::titlePanel("MAniR"),
- shiny::p("Explore and compare pairwise similarity, correlation or distance matrices."),
+ shiny::div(class = "workspace",
shiny::sidebarLayout(
shiny::sidebarPanel(width = 3,
- shiny::div(class = "metric",
- shiny::h4("Try an example"),
- shiny::p("Explore 12 fictional isolates, two similarity matrices and sample metadata. No upload needed."),
- shiny::actionButton("load_example", "Load example dataset",
- class = "btn-primary"),
- shiny::tags$br(), shiny::tags$br(),
- shiny::downloadButton("download_example", "Download example XLSX"),
- shiny::p(class = "small-help",
- "Synthetic data for demonstration, not real biological measurements.")
- ),
- shiny::uiOutput("active_analysis"),
- shiny::hr(),
- shiny::h4("Upload your data"),
- shiny::p(class = "small-help",
- "For very large matrices, CSV or RDS is preferable to Excel.
- Import uses RAM for the matrix and temporary validation data."),
- shiny::fileInput("first_file", "First matrix",
- accept = c(".xlsx", ".xlsm", ".csv", ".tsv", ".txt",
- ".gz", ".rds")),
- shiny::uiOutput("first_sheet_ui"),
- shiny::fileInput("second_file", "Second matrix file (optional)",
- accept = c(".xlsx", ".xlsm", ".csv", ".tsv", ".gz", ".rds")),
- shiny::uiOutput("second_sheet_ui"),
- shiny::fileInput("metadata_file", "Separate metadata file (optional)",
- accept = c(".xlsx", ".csv", ".tsv", ".rds")),
- shiny::uiOutput("metadata_sheet_ui"),
- shiny::fileInput("order_file", "Optional sample order (one ID per line)",
- accept = c(".txt", ".csv", ".tsv")),
- shiny::selectInput("kind1", "Matrix 1 represents",
- choices = c("Similarity" = "similarity", "Correlation" = "correlation",
- "Distance" = "distance", "Unspecified" = "auto")),
- shiny::selectInput("kind2", "Matrix 2 represents",
- choices = c("Similarity" = "similarity", "Correlation" = "correlation",
- "Distance" = "distance", "Unspecified" = "auto")),
- shiny::p(class = "small-help",
- "Similarity: larger values mean closer samples. Distance: smaller values mean closer samples. Correlation ranges from -1 to +1; larger values indicate a more positive relationship."),
- shiny::radioButtons("match_mode", "Compare samples",
- choices = c("Require identical IDs" = "strict",
- "Shared isolates only" = "intersection"),
- selected = "strict"),
- shiny::actionButton("visualize", "Load and analyze", class = "btn-primary"),
- shiny::p(class = "small-help",
- "Matrix type, linkage and sample matching are applied when you load a dataset. Palette, annotation track and zoom controls can change the current view without reloading."),
- shiny::hr(),
- shiny::h4("Visualization"),
- shiny::checkboxInput("cluster", "Cluster samples", value = TRUE),
- shiny::selectInput("linkage", "Clustering linkage",
- choices = c("Complete (historical default)" = "complete",
- "Average" = "average", "Single" = "single",
- "Ward D2" = "ward.D2"), selected = "complete"),
- shiny::selectInput("palette", "Palette",
- choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
- "Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
- selected = "RdBu"),
- shiny::checkboxInput("zoom_enabled", "Zoom into a region in large matrices", FALSE),
- shiny::numericInput("zoom_start", "First isolate index", 1, min = 1, step = 1),
- shiny::numericInput("zoom_size", "Isolates in zoom region", 300,
- min = 10, max = 1000, step = 50),
- shiny::checkboxInput("show_numbers", "Display cell numbers in small plots",
- value = FALSE),
- shiny::checkboxInput("log_scale", "Logarithmic display scaling",
- value = FALSE),
- shiny::selectInput("metadata_column", "Categorical annotation track",
- choices = c("None" = "")),
- shiny::checkboxInput("comparable_scales",
- "Both matrices measure the same quantity on the same scale",
- value = FALSE),
- shiny::p(class = "small-help",
- "Check this only for directly comparable measurements, such as ANI from two sequencing runs. Do not check it for ANI percentages versus MALDI scores, even after normalizing the heatmap colors."),
- shiny::helpText("For large matrices, plots use representative pixels. Numerical analyses and exports use original values unless stated otherwise."),
- shiny::hr(),
- shiny::h4("Statistical analysis"),
- shiny::numericInput("max_pairs", "Maximum plotted/analyzed pairs",
- 100000, min = 1000, max = 1000000, step = 1000),
- shiny::numericInput("cluster_k", "Number of clusters to compare (k)", 3,
- min = 2, max = 100),
- shiny::p(class = "small-help",
- "Cut both dendrograms into k groups and compare membership. Changing k can change every agreement statistic."),
- shiny::numericInput("permutations", "Mantel permutations", 999,
- min = 99, max = 9999),
- shiny::actionButton("run_mantel", "Run Mantel test"),
- shiny::p(class = "small-help",
- "The Mantel test checks association between matrices by permuting whole isolate labels, not individual cells. It requires complete matrices and at most 400 shared isolates; its p-value is not a test of typing-method equivalence.")
+ shiny::div(class = "control-sidebar",
+ shiny::div(class = "sidebar-scroll",
+ shiny::div(class = "example-quick",
+ shiny::h4("Try MAniR with example data"),
+ shiny::p("12 synthetic isolates with ANI-like, MALDI-like and metadata matrices."),
+ shiny::actionButton("load_example", "Load example", class = "btn-primary"),
+ shiny::tags$details(
+ shiny::tags$summary("About the example / download"),
+ shiny::p("Synthetic demonstration data, not measured biological values."),
+ shiny::downloadButton("download_example", "Example workbook (.xlsx)")
+ )
+ ),
+ shiny::tags$details(class = "control-group", open = "open",
+ shiny::tags$summary("Upload matrices"),
+ shiny::div(class = "group-body",
+ shiny::fileInput("first_file", "Matrix 1",
+ accept = c(".xlsx", ".xlsm", ".csv", ".tsv", ".txt", ".gz", ".rds")),
+ shiny::uiOutput("first_sheet_ui"),
+ shiny::fileInput("second_file", "Matrix 2 (optional)",
+ accept = c(".xlsx", ".xlsm", ".csv", ".tsv", ".gz", ".rds")),
+ shiny::uiOutput("second_sheet_ui"),
+ shiny::fileInput("metadata_file", "Metadata (optional)",
+ accept = c(".xlsx", ".csv", ".tsv", ".rds")),
+ shiny::uiOutput("metadata_sheet_ui"),
+ shiny::fileInput("order_file", "Sample order (optional)",
+ accept = c(".txt", ".csv", ".tsv")),
+ shiny::selectInput("kind1", "Matrix 1 represents",
+ choices = c("Similarity" = "similarity", "Correlation" = "correlation",
+ "Distance" = "distance", "Unspecified" = "auto")),
+ shiny::selectInput("kind2", "Matrix 2 represents",
+ choices = c("Similarity" = "similarity", "Correlation" = "correlation",
+ "Distance" = "distance", "Unspecified" = "auto")),
+ shiny::radioButtons("match_mode", "Compare samples",
+ choices = c("Identical IDs" = "strict",
+ "Shared isolates" = "intersection"),
+ selected = "strict"),
+ shiny::p(class = "small-help",
+ "Similarity: larger means closer. Distance: smaller means closer. Correlation is between -1 and 1.")
+ )
+ ),
+ shiny::tags$details(class = "control-group",
+ shiny::tags$summary("Analysis settings"),
+ shiny::div(class = "group-body",
+ shiny::checkboxInput("cluster", "Cluster samples on load", value = TRUE),
+ shiny::selectInput("linkage", "Clustering linkage",
+ choices = c("Complete (original default)" = "complete",
+ "Average" = "average", "Single" = "single",
+ "Ward D2" = "ward.D2"), selected = "complete"),
+ shiny::checkboxInput("comparable_scales",
+ "Same measurement and units in both matrices", value = FALSE),
+ shiny::p(class = "small-help",
+ "Only enable raw differences when measurements are directly comparable. ANI percentages and MALDI scores are different quantities."),
+ shiny::numericInput("max_pairs", "Maximum pairs to analyze",
+ 100000L, min = 1000L, max = 1000000L, step = 1000L),
+ shiny::numericInput("permutations", "Mantel permutations",
+ 999L, min = 99L, max = 9999L),
+ shiny::p(class = "small-help",
+ "Changing the matrix types, linkage, matching rule or clustering requires loading again. Display controls on the right update immediately.")
+ )
+ )
+ ),
+ shiny::div(class = "sidebar-footer",
+ shiny::actionButton("visualize", "Load and analyze uploaded files",
+ class = "btn-primary"),
+ shiny::uiOutput("sidebar_status")
+ )
+ )
),
shiny::mainPanel(width = 9,
shiny::div(class = "main-panel",
+ shiny::div(class = "results-toolbar",
+ shiny::div(class = "toolbar-field",
+ shiny::selectInput("palette", "Colors",
+ choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
+ "Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
+ selected = "RdBu")
+ ),
+ shiny::div(class = "toolbar-field",
+ shiny::selectInput("metadata_column", "Annotation",
+ choices = c("None" = ""))
+ ),
+ shiny::div(class = "toolbar-field control-checkbox",
+ shiny::checkboxInput("show_numbers", "Show cell values", value = FALSE)
+ ),
+ shiny::conditionalPanel(
+ condition = "input.results_tab === 'Cluster comparison'",
+ shiny::div(class = "toolbar-field contextual",
+ shiny::numericInput("cluster_k", "Clusters (k)",
+ 3L, min = 2L, max = 100L)
+ )
+ ),
+ shiny::conditionalPanel(
+ condition = "input.results_tab === 'Overview'",
+ shiny::div(class = "toolbar-field control-checkbox",
+ shiny::actionButton("run_mantel", "Run Mantel test",
+ class = "btn-default btn-sm")
+ )
+ ),
+ shiny::tags$details(class = "toolbar-more",
+ shiny::tags$summary("Display options"),
+ shiny::div(class = "toolbar-extra",
+ shiny::div(class = "toolbar-field control-checkbox",
+ shiny::checkboxInput("log_scale", "Log(1+x) color scaling",
+ value = FALSE)
+ ),
+ shiny::div(class = "toolbar-field control-checkbox",
+ shiny::checkboxInput("zoom_enabled", "Zoom into large matrices",
+ value = FALSE)
+ ),
+ shiny::conditionalPanel(
+ condition = "input.zoom_enabled",
+ shiny::div(class = "toolbar-field",
+ shiny::numericInput("zoom_start", "First isolate",
+ 1L, min = 1L, step = 1L)
+ ),
+ shiny::div(class = "toolbar-field",
+ shiny::numericInput("zoom_size", "Number of isolates",
+ 300L, min = 10L, max = 1000L, step = 50L)
+ )
+ )
+ )
+ ),
+ shiny::div(class = "toolbar-status",
+ shiny::uiOutput("active_analysis")
+ )
+ ),
+ shiny::div(class = "results-shell",
shiny::tabsetPanel(id = "results_tab",
shiny::tabPanel("Overview",
manir_guide(
@@ -180,7 +230,9 @@ ui <- shiny::fluidPage(
"Each dot is one distinct, unordered isolate pair. Its X position is the pair's value in matrix 1 and its Y position is the value for the same pair in matrix 2. Neither the diagonal nor mirrored duplicates are counted.",
"An upward pattern suggests positive association, while points far from the main pattern merit closer inspection. Different units are allowed here: compare ranks or correlations, not the raw distance from a one-to-one line. Pairwise observations share isolates, so they are not statistically independent."),
shiny::uiOutput("pairwise_intro"),
- shiny::plotOutput("scatter", height = "520px"),
+ shiny::div(class = "plot-frame",
+ shiny::plotOutput("scatter", height = "100%")
+ ),
shiny::h4("Pairs with the largest rank differences"),
shiny::p(class = "small-help",
"These are exploratory discrepancies in the relative ordering of pairs, not statistically significant outliers. A rank gap near 0 means the pair has a similar position among all evaluated pairs in both matrices. Orange rings mark up to five of these pairs in the scatterplot."),
@@ -224,7 +276,9 @@ ui <- shiny::fluidPage(
shiny::downloadButton("download_settings", "Analysis settings (text)")
)
)
+ )
)
)
)
+ )
)
From bf6c50f443e723abcc8643a4c3d1ee6aae48332d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:58:02 +0200
Subject: [PATCH 095/134] ui: size all matrix plots to available result
viewport and show loaded-data status
---
server.R | 27 +++++++++++++++++++++------
1 file changed, 21 insertions(+), 6 deletions(-)
diff --git a/server.R b/server.R
index 219d59b..c0044b3 100644
--- a/server.R
+++ b/server.R
@@ -145,6 +145,18 @@ server <- function(input, output, session) {
})
}
loaded <- shiny::reactiveVal(NULL)
+ output$sidebar_status <- shiny::renderUI({
+ d <- loaded()
+ if (is.null(d))
+ return(shiny::span(class = "load-status",
+ "Choose the example or upload matrices to begin."))
+ shiny::span(class = "load-status",
+ sprintf("Loaded: %s | %s isolates%s", d$source,
+ format(nrow(d$first), big.mark = ","),
+ if (is.null(d$second)) "" else
+ sprintf(" | %s in matrix 2",
+ format(nrow(d$second), big.mark = ","))))
+ })
shiny::observeEvent(input$visualize, {
loaded(analyze_input(example = FALSE))
})
@@ -348,13 +360,16 @@ server <- function(input, output, session) {
m <- source()
size <- if (combined || is_difference) nrow(m$first) else nrow(m)
if (size <= 300L)
- plotly::plotlyOutput(paste0(prefix, "_interactive"),
- width = "100%", height = "750px")
+ shiny::div(class = "plot-frame",
+ plotly::plotlyOutput(paste0(prefix, "_interactive"),
+ width = "100%", height = "100%"))
else shiny::tagList(
- shiny::p("Large-matrix raster preview. Click a cell to inspect its exact original value."),
- shiny::plotOutput(paste0(prefix, "_raster"),
- width = "100%", height = "850px",
- click = paste0(prefix, "_click"))
+ shiny::p(class = "small-help",
+ "Representative raster preview. Click a cell for its exact original value. Use Display options to zoom."),
+ shiny::div(class = "plot-frame",
+ shiny::plotOutput(paste0(prefix, "_raster"),
+ width = "100%", height = "100%",
+ click = paste0(prefix, "_click")))
)
})
output[[paste0(prefix, "_interactive")]] <- plotly::renderPlotly({
From c32097c3abe148401e413ec9353afd0796048cc4 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:58:42 +0200
Subject: [PATCH 096/134] test: confirm workspace controls remain unique and
independently scrollable
---
tests/run_tests.R | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 0195ecd..b7fbb04 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -298,6 +298,26 @@ test("metadata colors keep their meaning across both matrix orders", {
logical(1L))))
})
+test("workspace separates scrolling inputs from persistent live controls", {
+ source("ui.R", local = TRUE)
+ html <- htmltools::renderTags(ui)$html
+ for (expected in c("workspace", "control-sidebar", "sidebar-scroll",
+ "sidebar-footer", "results-toolbar", "results-shell",
+ "Display options", "workspace.css")) {
+ stopifnot(grepl(expected, html, fixed = TRUE))
+ }
+ # Repeated Shiny input IDs would lead to controls updating the wrong value.
+ for (id in c("palette", "metadata_column", "show_numbers", "cluster_k",
+ "zoom_enabled", "visualize", "load_example")) {
+ found <- gregexpr(paste0('id="', id, '"'), html, fixed = TRUE)[[1L]]
+ stopifnot(length(found) == 1L, found[1L] != -1L)
+ }
+ css <- paste(readLines("www/workspace.css", warn = FALSE), collapse = "\n")
+ stopifnot(grepl("overflow-y: auto", css, fixed = TRUE),
+ grepl("max-width: 991px", css, fixed = TRUE),
+ grepl(".plot-frame", css, fixed = TRUE))
+})
+
test("every result tab includes contextual interpretation guidance", {
source("ui.R", local = TRUE)
view <- htmltools::renderTags(ui)$html
From 1a9a0b79f40b35e40964065f466384ba5e051c6b Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:58:45 +0200
Subject: [PATCH 097/134] docs: explain persistent display toolbar and
independent result scrolling
---
README.md | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/README.md b/README.md
index d79f7b9..63a36cd 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,27 @@ Click **Download example XLSX** to obtain a workbook with `ANI`, `MALDI`,
`examples/`. To upload the workbook yourself, select ANI as the first sheet,
MALDI as the second and metadata as the annotation sheet.
+## Workspace layout
+
+On a desktop window, MAniR keeps the analysis results and control sidebar in
+**separately scrollable panes**. The lower-left **Load and analyze uploaded
+files** button stays visible when you scroll through the upload options.
+The right-hand toolbar remains visible when you scroll through a result.
+
+Use the right-hand **Colors**, **Annotation**, and **Show cell values** controls
+for instant plot changes without re-importing the dataset. **Display options**
+contains log color scaling and region zoom for large matrices. The **Clusters
+(k)** control appears in the Cluster comparison tab; **Run Mantel test**
+appears in Overview. Matrix type, clustering linkage and sample matching are
+load-time analysis settings and remain under the left-hand **Analysis settings**
+section. Changes to those settings take effect after clicking **Load and
+analyze uploaded files**. Help text is collapsed to one line above plots so the
+visualization is visible immediately; expand it for more detailed
+interpretation.
+
+On small screens or short windows, MAniR switches to a normal scrolling page
+rather than forcing two narrow, separately scrolling panes.
+
## Input formats
- **XLSX / XLSM:** one matrix per worksheet, with sample IDs in the first row and first column. Optional second matrix and metadata sheets may be selected from the same workbook. A second workbook is also supported.
From 11ef1418bb6f40a7ad1635f7c4997dc9a711192a Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:58:47 +0200
Subject: [PATCH 098/134] docs: record desktop and responsive layout
improvements
---
docs/UPGRADE_WORKLOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/docs/UPGRADE_WORKLOG.md b/docs/UPGRADE_WORKLOG.md
index f5bd098..a3d9f96 100644
--- a/docs/UPGRADE_WORKLOG.md
+++ b/docs/UPGRADE_WORKLOG.md
@@ -78,3 +78,10 @@ these checks pass. The arXiv manuscript is outside this work scope.
matching ANI- and MALDI-like matrices and metadata. The upgraded interface
offers a one-click loader and an XLSX download; numerical and Shiny tests
cover both the demo and switching back to user uploads.
+
+- Reorganized Shiny into a desktop split workspace with independently scrolling
+ upload options and results. Moved live palette, metadata, cell labels and
+ context-sensitive statistical controls to a persistent results toolbar.
+ Upload action stays visible in a fixed sidebar footer, interpretation text
+ starts collapsed, plots size to viewport and narrow windows fall back to
+ normal scrolling. Added UI structure regression checks.
From ee7f37ce6fb87c0b7342079dec35aa59f14022a9 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:00:23 +0200
Subject: [PATCH 099/134] test: inspect hoisted Shiny stylesheet in HTML head
and report missing UI components
---
tests/run_tests.R | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index b7fbb04..6db10a4 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -300,12 +300,16 @@ test("metadata colors keep their meaning across both matrix orders", {
test("workspace separates scrolling inputs from persistent live controls", {
source("ui.R", local = TRUE)
- html <- htmltools::renderTags(ui)$html
+ rendered <- htmltools::renderTags(ui)
+ html <- rendered$html
for (expected in c("workspace", "control-sidebar", "sidebar-scroll",
"sidebar-footer", "results-toolbar", "results-shell",
- "Display options", "workspace.css")) {
- stopifnot(grepl(expected, html, fixed = TRUE))
+ "Display options")) {
+ if (!grepl(expected, html, fixed = TRUE))
+ stop("Missing expected workspace element: ", expected)
}
+ # htmltools hoists stylesheets from the body into the document head.
+ stopifnot(grepl("workspace.css", rendered$head, fixed = TRUE))
# Repeated Shiny input IDs would lead to controls updating the wrong value.
for (id in c("palette", "metadata_column", "show_numbers", "cluster_k",
"zoom_enabled", "visualize", "load_example")) {
From 159efa76d51eef04e014939a3249eabee6634ec1 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:03:00 +0200
Subject: [PATCH 100/134] ui: group five matrix views under Heatmaps and show
only controls relevant to the active result
---
ui.R | 141 ++++++++++++++++++++++++++++++++++-------------------------
1 file changed, 82 insertions(+), 59 deletions(-)
diff --git a/ui.R b/ui.R
index d14514f..94a6cc6 100644
--- a/ui.R
+++ b/ui.R
@@ -106,8 +106,6 @@ ui <- shiny::fluidPage(
"Same measurement and units in both matrices", value = FALSE),
shiny::p(class = "small-help",
"Only enable raw differences when measurements are directly comparable. ANI percentages and MALDI scores are different quantities."),
- shiny::numericInput("max_pairs", "Maximum pairs to analyze",
- 100000L, min = 1000L, max = 1000000L, step = 1000L),
shiny::numericInput("permutations", "Mantel permutations",
999L, min = 99L, max = 9999L),
shiny::p(class = "small-help",
@@ -125,18 +123,32 @@ ui <- shiny::fluidPage(
shiny::mainPanel(width = 9,
shiny::div(class = "main-panel",
shiny::div(class = "results-toolbar",
- shiny::div(class = "toolbar-field",
- shiny::selectInput("palette", "Colors",
- choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
- "Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
- selected = "RdBu")
+ shiny::conditionalPanel(
+ condition = "input.results_tab === 'Heatmaps'",
+ shiny::div(class = "toolbar-field",
+ shiny::selectInput("palette", "Colors",
+ choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
+ "Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
+ selected = "RdBu")
+ ),
+
),
shiny::div(class = "toolbar-field",
shiny::selectInput("metadata_column", "Annotation",
choices = c("None" = ""))
),
- shiny::div(class = "toolbar-field control-checkbox",
- shiny::checkboxInput("show_numbers", "Show cell values", value = FALSE)
+ shiny::conditionalPanel(
+ condition = "input.results_tab === 'Heatmaps'",
+ shiny::div(class = "toolbar-field control-checkbox",
+ shiny::checkboxInput("show_numbers", "Show cell values", value = FALSE)
+ )
+ ),
+ shiny::conditionalPanel(
+ condition = "input.results_tab === 'Pairwise comparison'",
+ shiny::div(class = "toolbar-field contextual pair-limit",
+ shiny::numericInput("max_pairs", "Max pairs",
+ 100000L, min = 1000L, max = 1000000L, step = 1000L)
+ )
),
shiny::conditionalPanel(
condition = "input.results_tab === 'Cluster comparison'",
@@ -152,29 +164,33 @@ ui <- shiny::fluidPage(
class = "btn-default btn-sm")
)
),
- shiny::tags$details(class = "toolbar-more",
- shiny::tags$summary("Display options"),
- shiny::div(class = "toolbar-extra",
- shiny::div(class = "toolbar-field control-checkbox",
- shiny::checkboxInput("log_scale", "Log(1+x) color scaling",
- value = FALSE)
- ),
- shiny::div(class = "toolbar-field control-checkbox",
- shiny::checkboxInput("zoom_enabled", "Zoom into large matrices",
- value = FALSE)
- ),
- shiny::conditionalPanel(
- condition = "input.zoom_enabled",
- shiny::div(class = "toolbar-field",
- shiny::numericInput("zoom_start", "First isolate",
- 1L, min = 1L, step = 1L)
+ shiny::conditionalPanel(
+ condition = "input.results_tab === 'Heatmaps'",
+ shiny::tags$details(class = "toolbar-more",
+ shiny::tags$summary("Display options"),
+ shiny::div(class = "toolbar-extra",
+ shiny::div(class = "toolbar-field control-checkbox",
+ shiny::checkboxInput("log_scale", "Log(1+x) color scaling",
+ value = FALSE)
+ ),
+ shiny::div(class = "toolbar-field control-checkbox",
+ shiny::checkboxInput("zoom_enabled", "Zoom into large matrices",
+ value = FALSE)
),
- shiny::div(class = "toolbar-field",
- shiny::numericInput("zoom_size", "Number of isolates",
- 300L, min = 10L, max = 1000L, step = 50L)
+ shiny::conditionalPanel(
+ condition = "input.zoom_enabled",
+ shiny::div(class = "toolbar-field",
+ shiny::numericInput("zoom_start", "First isolate",
+ 1L, min = 1L, step = 1L)
+ ),
+ shiny::div(class = "toolbar-field",
+ shiny::numericInput("zoom_size", "Number of isolates",
+ 300L, min = 10L, max = 1000L, step = 50L)
+ )
)
)
- )
+ ),
+
),
shiny::div(class = "toolbar-status",
shiny::uiOutput("active_analysis")
@@ -195,36 +211,43 @@ ui <- shiny::fluidPage(
shiny::p(class = "small-help",
"Select a metadata category in the sidebar to describe how the first matrix differs within and between those groups. These are descriptive pairwise summaries, not an independent-sample test."),
shiny::tableOutput("replicate_table")),
- shiny::tabPanel("Matrix 1",
- manir_guide(
- "Every cell compares the isolate named on its row with the isolate named on its column. The diagonal compares an isolate with itself. Larger values mean more similar samples only for similarity and positive-correlation measurements.",
- "Clustering puts isolates with related measurements next to each other; it does not change the values. The color bar shows a 0–1 display scale based on the data range, not necessarily the original numerical units. Hover over a cell for its original value. Metadata colors identify categories, not measured similarity."),
- shiny::uiOutput("plot1_intro"), shiny::uiOutput("plot1_legend"),
- shiny::uiOutput("plot1_ui"), shiny::verbatimTextOutput("plot1_cell")),
- shiny::tabPanel("Matrix 2",
- manir_guide(
- "Read this matrix in the same way as matrix 1. Its clustering order can differ, and the display colors are scaled to this matrix's own numerical range.",
- "Equal colors across the separate Matrix 1 and Matrix 2 plots do not imply equal biological measurements. Hover over any cell for the exact original value. Choose the same metadata track to see whether visual clusters correspond to the same sample categories."),
- shiny::uiOutput("plot2_intro"), shiny::uiOutput("plot2_legend"),
- shiny::uiOutput("plot2_ui"), shiny::verbatimTextOutput("plot2_cell")),
- shiny::tabPanel("Combined (order 1)",
- manir_guide(
- "This split heatmap places matrix 1 above the diagonal and matrix 2 below it. Both triangles use the ordering calculated from matrix 1, so the same pair can be inspected in both methods.",
- "Each triangle is normalized independently for display. Comparing the position of clusters is meaningful; directly comparing two color shades or subtracting their display values is not. The diagonal is displayed with a neutral midpoint color because it separates the two methods, not because its original measurements equal 0.5."),
- shiny::uiOutput("combined1_intro"), shiny::uiOutput("combined1_legend"),
- shiny::uiOutput("combined1_ui"), shiny::verbatimTextOutput("combined1_cell")),
- shiny::tabPanel("Combined (order 2)",
- manir_guide(
- "The same split comparison, now ordered using matrix 2. The upper triangle is matrix 2 and the lower triangle is matrix 1.",
- "Switch between both combined tabs to see how each method's ordering groups isolates. Triangle colors remain independently scaled and the diagonal is a visual separator. Inspect original values in the cell hover rather than using color as a shared measurement."),
- shiny::uiOutput("combined2_intro"), shiny::uiOutput("combined2_legend"),
- shiny::uiOutput("combined2_ui"), shiny::verbatimTextOutput("combined2_cell")),
- shiny::tabPanel("Difference",
- manir_guide(
- "This tab calculates matrix 1 minus matrix 2 for each shared isolate pair. Zero is the midpoint of the diverging color scale; opposite sides represent opposite signs.",
- "A difference is interpretable only when both matrices measure the same quantity in the same units and have compatible preprocessing. Two different measurement types, such as ANI percentages and MALDI spectral scores, should be examined with rank comparisons instead."),
- shiny::uiOutput("difference_explainer"), shiny::uiOutput("difference_ui"),
- shiny::verbatimTextOutput("difference_cell")),
+ shiny::tabPanel("Heatmaps",
+ shiny::div(class = "matrix-nav",
+ shiny::tabsetPanel(id = "matrix_view", type = "pills",
+ shiny::tabPanel("Matrix 1",
+ manir_guide(
+ "Every cell compares the isolate named on its row with the isolate named on its column. The diagonal compares an isolate with itself. Larger values mean more similar samples only for similarity and positive-correlation measurements.",
+ "Clustering puts isolates with related measurements next to each other; it does not change the values. The color bar shows a 0–1 display scale based on the data range, not necessarily the original numerical units. Hover over a cell for its original value. Metadata colors identify categories, not measured similarity."),
+ shiny::uiOutput("plot1_intro"), shiny::uiOutput("plot1_legend"),
+ shiny::uiOutput("plot1_ui"), shiny::verbatimTextOutput("plot1_cell")),
+ shiny::tabPanel("Matrix 2",
+ manir_guide(
+ "Read this matrix in the same way as matrix 1. Its clustering order can differ, and the display colors are scaled to this matrix's own numerical range.",
+ "Equal colors across the separate Matrix 1 and Matrix 2 plots do not imply equal biological measurements. Hover over any cell for the exact original value. Choose the same metadata track to see whether visual clusters correspond to the same sample categories."),
+ shiny::uiOutput("plot2_intro"), shiny::uiOutput("plot2_legend"),
+ shiny::uiOutput("plot2_ui"), shiny::verbatimTextOutput("plot2_cell")),
+ shiny::tabPanel("Combined (order 1)",
+ manir_guide(
+ "This split heatmap places matrix 1 above the diagonal and matrix 2 below it. Both triangles use the ordering calculated from matrix 1, so the same pair can be inspected in both methods.",
+ "Each triangle is normalized independently for display. Comparing the position of clusters is meaningful; directly comparing two color shades or subtracting their display values is not. The diagonal is displayed with a neutral midpoint color because it separates the two methods, not because its original measurements equal 0.5."),
+ shiny::uiOutput("combined1_intro"), shiny::uiOutput("combined1_legend"),
+ shiny::uiOutput("combined1_ui"), shiny::verbatimTextOutput("combined1_cell")),
+ shiny::tabPanel("Combined (order 2)",
+ manir_guide(
+ "The same split comparison, now ordered using matrix 2. The upper triangle is matrix 2 and the lower triangle is matrix 1.",
+ "Switch between both combined tabs to see how each method's ordering groups isolates. Triangle colors remain independently scaled and the diagonal is a visual separator. Inspect original values in the cell hover rather than using color as a shared measurement."),
+ shiny::uiOutput("combined2_intro"), shiny::uiOutput("combined2_legend"),
+ shiny::uiOutput("combined2_ui"), shiny::verbatimTextOutput("combined2_cell")),
+ shiny::tabPanel("Difference",
+ manir_guide(
+ "This tab calculates matrix 1 minus matrix 2 for each shared isolate pair. Zero is the midpoint of the diverging color scale; opposite sides represent opposite signs.",
+ "A difference is interpretable only when both matrices measure the same quantity in the same units and have compatible preprocessing. Two different measurement types, such as ANI percentages and MALDI spectral scores, should be examined with rank comparisons instead."),
+ shiny::uiOutput("difference_explainer"), shiny::uiOutput("difference_ui"),
+ shiny::verbatimTextOutput("difference_cell")),
+
+ )
+ )
+ ),
shiny::tabPanel("Pairwise comparison",
manir_guide(
"Each dot is one distinct, unordered isolate pair. Its X position is the pair's value in matrix 1 and its Y position is the value for the same pair in matrix 2. Neither the diagonal nor mirrored duplicates are counted.",
From 9f7c798cbb3c121c0e4f90c132ae20c6ea6f02a5 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:03:11 +0200
Subject: [PATCH 101/134] fix: close grouped heatmap tabs without a trailing
empty argument
---
ui.R | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/ui.R b/ui.R
index 94a6cc6..640b37a 100644
--- a/ui.R
+++ b/ui.R
@@ -243,8 +243,7 @@ ui <- shiny::fluidPage(
"This tab calculates matrix 1 minus matrix 2 for each shared isolate pair. Zero is the midpoint of the diverging color scale; opposite sides represent opposite signs.",
"A difference is interpretable only when both matrices measure the same quantity in the same units and have compatible preprocessing. Two different measurement types, such as ANI percentages and MALDI spectral scores, should be examined with rank comparisons instead."),
shiny::uiOutput("difference_explainer"), shiny::uiOutput("difference_ui"),
- shiny::verbatimTextOutput("difference_cell")),
-
+ shiny::verbatimTextOutput("difference_cell"))
)
)
),
From 0c18bf502779c258413ccb19523c962d12c25758 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:03:24 +0200
Subject: [PATCH 102/134] style: add compact subnavigation for five heatmaps
and context-sensitive toolbar widths
---
www/workspace.css | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/www/workspace.css b/www/workspace.css
index b541375..206b9f4 100644
--- a/www/workspace.css
+++ b/www/workspace.css
@@ -115,6 +115,30 @@ body { background: var(--manir-bg); color: var(--manir-ink); }
padding: 8px 20px 24px;
}
.results-shell .tab-pane { max-width: 100%; }
+/* Five matrix views share a single primary tab to keep navigation short. */
+.results-shell .matrix-nav > .tabbable { display: block; }
+.results-shell .matrix-nav .nav-pills {
+ display: flex; flex-wrap: nowrap; gap: 3px; overflow-x: auto;
+ border-bottom: 1px solid var(--manir-border); padding: 0 0 9px;
+ margin-bottom: 8px; scrollbar-width: thin;
+}
+.results-shell .matrix-nav .nav-pills > li {
+ float: none; flex: 0 0 auto; margin: 0;
+}
+.results-shell .matrix-nav .nav-pills > li > a {
+ border: 1px solid var(--manir-border); border-radius: 6px;
+ background: #f8fbfc; color: #3c6076; font-size: 12px;
+ padding: 7px 11px; margin: 0;
+}
+.results-shell .matrix-nav .nav-pills > li.active > a {
+ border-color: #bfd6e1; background: #e9f4f9; color: #185c79; font-weight: 650;
+}
+.results-shell .matrix-nav .tab-content {
+ overflow: visible; flex: none; min-height: auto; padding: 0;
+}
+.matrix-nav .plot-frame { height: clamp(395px, calc(100dvh - 355px), 850px); }
+.pair-limit .shiny-input-container { width: 150px !important; }
+
.results-shell .reading-guide {
background: #f3f8fb; border: 1px solid #dae7ee;
border-left: 3px solid #52859e; border-radius: 5px;
@@ -156,7 +180,7 @@ body { background: var(--manir-bg); color: var(--manir-ink); }
.workspace .main-panel { overflow: visible; }
.results-shell > .tabbable { display: block; }
.results-shell .tab-content { overflow: visible; }
- .plot-frame { height: min(68vh, 700px); min-height: 380px; }
+ .plot-frame, .matrix-nav .plot-frame { height: min(68vh, 700px); min-height: 380px; }
.toolbar-status { text-align: left; max-width: none; }
}
@media (max-width: 600px) {
From cc733d09e37fc597d56b0b8266fa18a5feadc6fc Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:03:47 +0200
Subject: [PATCH 103/134] fix: avoid empty trailing arguments in
context-specific Shiny toolbar panels
---
ui.R | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/ui.R b/ui.R
index 640b37a..3aacc41 100644
--- a/ui.R
+++ b/ui.R
@@ -130,8 +130,7 @@ ui <- shiny::fluidPage(
choices = c("RdBu", "BrBG", "PiYG", "PRGn", "PuOr", "RdYlBu",
"Viridis", "YlOrRd", "Blues", "Greens", "Greys"),
selected = "RdBu")
- ),
-
+ )
),
shiny::div(class = "toolbar-field",
shiny::selectInput("metadata_column", "Annotation",
@@ -189,8 +188,7 @@ ui <- shiny::fluidPage(
)
)
)
- ),
-
+ )
),
shiny::div(class = "toolbar-status",
shiny::uiOutput("active_analysis")
From 47867b20f4ecb92f05f817d5c991197c1bfe7d8d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:04:04 +0200
Subject: [PATCH 104/134] ui: jump directly to the first heatmap after an
example or file load
---
server.R | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/server.R b/server.R
index c0044b3..0da3678 100644
--- a/server.R
+++ b/server.R
@@ -157,8 +157,18 @@ server <- function(input, output, session) {
sprintf(" | %s in matrix 2",
format(nrow(d$second), big.mark = ","))))
})
+ # After loading, open the result rather than leaving the researcher
+ # at an empty Overview tab. Display-only controls then update it in place.
+ show_loaded_heatmap <- function() {
+ shiny::updateTabsetPanel(session, "results_tab", selected = "Heatmaps")
+ shiny::updateTabsetPanel(session, "matrix_view", selected = "Matrix 1")
+ }
shiny::observeEvent(input$visualize, {
- loaded(analyze_input(example = FALSE))
+ result <- analyze_input(example = FALSE)
+ if (!is.null(result)) {
+ loaded(result)
+ show_loaded_heatmap()
+ }
})
shiny::observeEvent(input$load_example, {
shiny::updateCheckboxInput(session, "comparable_scales", value = FALSE)
@@ -168,7 +178,11 @@ server <- function(input, output, session) {
shiny::updateCheckboxInput(session, "cluster", value = TRUE)
shiny::updateSelectInput(session, "linkage", selected = "complete")
shiny::updateNumericInput(session, "cluster_k", value = 3)
- loaded(analyze_input(example = TRUE))
+ result <- analyze_input(example = TRUE)
+ if (!is.null(result)) {
+ loaded(result)
+ show_loaded_heatmap()
+ }
})
output$download_example <- shiny::downloadHandler(
filename = function() "MAniR_synthetic_example.xlsx",
From 98e9a57a6c38d267ac0b915d9374c7a71444b330 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:04:31 +0200
Subject: [PATCH 105/134] test: verify nested matrix navigation in responsive
workspace
---
tests/run_tests.R | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 6db10a4..43cc3b4 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -308,6 +308,9 @@ test("workspace separates scrolling inputs from persistent live controls", {
if (!grepl(expected, html, fixed = TRUE))
stop("Missing expected workspace element: ", expected)
}
+ # The primary navigation keeps its five matrix views in a single tab.
+ stopifnot(grepl('id="matrix_view"', html, fixed = TRUE),
+ grepl("Heatmaps", html, fixed = TRUE))
# htmltools hoists stylesheets from the body into the document head.
stopifnot(grepl("workspace.css", rendered$head, fixed = TRUE))
# Repeated Shiny input IDs would lead to controls updating the wrong value.
From 37a464b6111ed0d7d5e4dcfbb77d390e2e098438 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:04:34 +0200
Subject: [PATCH 106/134] docs: describe grouped heatmap navigation and
context-specific controls
---
README.md | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 63a36cd..1c09b90 100644
--- a/README.md
+++ b/README.md
@@ -54,11 +54,18 @@ On a desktop window, MAniR keeps the analysis results and control sidebar in
files** button stays visible when you scroll through the upload options.
The right-hand toolbar remains visible when you scroll through a result.
+After you load data, MAniR opens **Heatmaps → Matrix 1** automatically.
+The primary navigation groups all five heatmap views under **Heatmaps**, with
+a compact secondary menu for Matrix 1, Matrix 2, both combined orderings
+and Difference. Other tabs are Overview, Pairwise comparison, Cluster
+comparison, Metadata and Export.
+
Use the right-hand **Colors**, **Annotation**, and **Show cell values** controls
-for instant plot changes without re-importing the dataset. **Display options**
-contains log color scaling and region zoom for large matrices. The **Clusters
-(k)** control appears in the Cluster comparison tab; **Run Mantel test**
-appears in Overview. Matrix type, clustering linkage and sample matching are
+for instant plot changes without re-importing the dataset. The palette and
+cell-value options appear only in Heatmaps. **Display options** contains log
+color scaling and region zoom for large matrices. The **Clusters (k)** control
+appears in Cluster comparison, the **Max pairs** control appears in Pairwise
+comparison, and **Run Mantel test** appears in Overview. Matrix type, clustering linkage and sample matching are
load-time analysis settings and remain under the left-hand **Analysis settings**
section. Changes to those settings take effect after clicking **Load and
analyze uploaded files**. Help text is collapsed to one line above plots so the
@@ -100,7 +107,7 @@ To bound memory use, the pairwise tool examines up to 100,000 distinct pairs by
The Mantel test permutes isolate labels jointly in one matrix. It reports a two-sided permutation p-value using `(extreme + 1) / (permutations + 1)`. The current implementation requires complete matched matrices with at most 400 isolates. The Mantel statistic measures matrix association; it does not establish that two typing methods produce equivalent classifications.
-Cluster comparison creates average-linkage group assignments on shared isolates and reports the adjusted Rand index and the directional adjusted Wallace coefficients. The number of clusters is user-selectable and both matrices must have at most 2,000 shared isolates. Pairwise similarities and clustering distances are kept conceptually distinct. The metadata group summary describes within-group versus between-group pairwise values, which may be helpful for MALDI repeatability or batch studies. That descriptive group comparison is not a formal batch-effect test.
+Cluster comparison creates the selected-linkage group assignments on shared isolates and reports the adjusted Rand index and the directional adjusted Wallace coefficients. The number of clusters is user-selectable and both matrices must have at most 2,000 shared isolates. Pairwise similarities and clustering distances are kept conceptually distinct. The metadata group summary describes within-group versus between-group pairwise values, which may be helpful for MALDI repeatability or batch studies. That descriptive group comparison is not a formal batch-effect test.
MAniR accepts **precomputed** MALDI-TOF, ANI, dDDH and other pairwise results. Raw peak detection, spectral calibration and alignment remain the responsibility of specialized preprocessing software. Feeding differently normalized matrices into MAniR does not make the underlying measurement scales interchangeable.
From d3f5b6711903c123e2ecb5b1cac02edb135016d2 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:04:37 +0200
Subject: [PATCH 107/134] docs: record grouped results tabs and automatic first
plot
---
docs/UPGRADE_WORKLOG.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/docs/UPGRADE_WORKLOG.md b/docs/UPGRADE_WORKLOG.md
index a3d9f96..f6f3085 100644
--- a/docs/UPGRADE_WORKLOG.md
+++ b/docs/UPGRADE_WORKLOG.md
@@ -85,3 +85,8 @@ these checks pass. The arXiv manuscript is outside this work scope.
Upload action stays visible in a fixed sidebar footer, interpretation text
starts collapsed, plots size to viewport and narrow windows fall back to
normal scrolling. Added UI structure regression checks.
+
+- Grouped five heatmap views under a single primary Heatmaps tab and added
+ context-sensitive controls: color and zoom only for heatmaps, pair limits
+ only for pairwise analysis, and k only for cluster comparison. Successful
+ loading now opens Matrix 1 automatically, without page scrolling.
From 28922782e8eae73fccffe60d27661cd95b5d56a1 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:05:51 +0200
Subject: [PATCH 108/134] docs: align example, zoom and pair-limit instructions
with compact interface
---
README.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 1c09b90..715884e 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ For scripted validation, run `Rscript tests/run_tests.R`. For repeatable perform
## Built-in example
-Click **Load example dataset** in the application sidebar to explore MAniR
+Click **Load example** in the application sidebar to explore MAniR
without uploading anything. The example contains 12 **fictional** isolates in
three groups, with two precomputed symmetric similarity matrices:
@@ -42,7 +42,7 @@ scales are different, so leave "Both matrices use comparable numerical units"
unchecked. All entries are illustrative and must not be used for biological
inference or cited as measured data.
-Click **Download example XLSX** to obtain a workbook with `ANI`, `MALDI`,
+Open **About the example / download** and click **Example workbook (.xlsx)** to obtain a workbook with `ANI`, `MALDI`,
`metadata` and `README` sheets. The equivalent editable CSV files are in
`examples/`. To upload the workbook yourself, select ANI as the first sheet,
MALDI as the second and metadata as the annotation sheet.
@@ -93,7 +93,7 @@ For two matrices, choose whether the isolate lists must match exactly or whether
Smaller matrices (300 isolates or fewer) are displayed as interactive Plotly heatmaps with exact hover values. Number overlays are available for at most 70 isolates. For larger matrices, the default raster viewer displays up to 512 representative rows and columns rather than creating a browser annotation for every cell. This is a **representative overview**, not an aggregated matrix or a numerical substitute for the source data. Click a displayed cell to inspect the exact underlying matrix entry.
-Enable "Zoom into a region" and enter the first isolate index and the region size to inspect adjacent samples. When a region exceeds 512 isolates, it is itself reduced to a representative 512-by-512 display; choose a smaller region to inspect every cell.
+Open **Display options** in the Heatmaps toolbar, enable **Zoom into large matrices**, and enter the first isolate index and the region size to inspect adjacent samples. When a region exceeds 512 isolates, it is itself reduced to a representative 512-by-512 display; choose a smaller region to inspect every cell.
The combined views use the first matrix above the diagonal and the second below the diagonal. One view uses the sample ordering from matrix 1; the other uses the ordering from matrix 2. Because ANI percentages and MALDI similarities can have different units, **each triangle is display-scaled independently**. Hover and click inspections, pairwise exports and RDS downloads retain the original numeric values. Log display scaling uses log(1+x) on nonnegative values (including a zero diagonal); negative correlations cannot be logarithmically displayed.
@@ -103,7 +103,7 @@ Clustering defaults to complete-linkage hierarchical clustering, matching the or
The pairwise comparison tab plots values from corresponding unordered isolate pairs, excluding the diagonal. It reports Pearson and Spearman correlation coefficients. If the matrices truly use comparable units and the corresponding checkbox is enabled, it also reports mean absolute and root mean squared differences, a one-to-one reference line and the isolate pairs with the largest absolute differences. Treat these statistics as descriptive; isolate-pair observations are not statistically independent.
-To bound memory use, the pairwise tool examines up to 100,000 distinct pairs by default. When the full dataset exceeds this limit, it selects a reproducible random subset (fixed seed 1), discloses the number of evaluated pairs and marks the analysis as sampled. Downloaded pairwise values follow the same selection. Change the pair limit in the sidebar for a larger subset.
+To bound memory use, the pairwise tool examines up to 100,000 distinct pairs by default. When the full dataset exceeds this limit, it selects a reproducible random subset (fixed seed 1), discloses the number of evaluated pairs and marks the analysis as sampled. Downloaded pairwise values follow the same selection. Change **Max pairs** in the Pairwise comparison toolbar for a larger subset.
The Mantel test permutes isolate labels jointly in one matrix. It reports a two-sided permutation p-value using `(extreme + 1) / (permutations + 1)`. The current implementation requires complete matched matrices with at most 400 isolates. The Mantel statistic measures matrix association; it does not establish that two typing methods produce equivalent classifications.
From a4c4c7ef198509f673e92368ccd5ecfa83ca544a Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:09:04 +0200
Subject: [PATCH 109/134] feat: add exact pair inspection, metadata comparisons
and reproducible research notes
---
R/research_workflows.R | 162 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 162 insertions(+)
create mode 100644 R/research_workflows.R
diff --git a/R/research_workflows.R b/R/research_workflows.R
new file mode 100644
index 0000000..cfa2e9f
--- /dev/null
+++ b/R/research_workflows.R
@@ -0,0 +1,162 @@
+# Researcher-facing summaries. These functions do not depend on Shiny or Plotly.
+# They use the original numerical values, never heatmap-normalized colors.
+
+manir_pair_inspection <- function(first, second = NULL, isolate_1, isolate_2,
+ metadata = NULL, field = NULL,
+ comparable = FALSE) {
+ ids <- colnames(first)
+ if (length(isolate_1) != 1L || length(isolate_2) != 1L ||
+ is.na(isolate_1) || is.na(isolate_2) ||
+ !isolate_1 %in% ids || !isolate_2 %in% ids ||
+ identical(isolate_1, isolate_2))
+ stop("Choose two different isolates present in the first matrix.")
+ if (!is.null(second) &&
+ (!isolate_1 %in% colnames(second) || !isolate_2 %in% colnames(second)))
+ stop("Both isolates must be present in the second matrix to compare them.")
+ value1 <- unname(first[isolate_1, isolate_2])
+ value2 <- if (is.null(second)) NA_real_ else
+ unname(second[isolate_1, isolate_2])
+ label1 <- label2 <- NA_character_
+ if (!is.null(metadata) && !is.null(field) && length(field) == 1L &&
+ !is.na(field) && nzchar(field) && field %in% names(metadata)) {
+ label1 <- if (isolate_1 %in% rownames(metadata))
+ as.character(metadata[isolate_1, field]) else NA_character_
+ label2 <- if (isolate_2 %in% rownames(metadata))
+ as.character(metadata[isolate_2, field]) else NA_character_
+ }
+ list(isolate_1 = isolate_1, isolate_2 = isolate_2,
+ first = value1, second = value2,
+ difference = if (isTRUE(comparable) && !is.null(second))
+ value1 - value2 else NULL,
+ metadata_field = if (is.null(field)) "" else field,
+ group_1 = label1, group_2 = label2)
+}
+
+# A descriptive within/between-category summary, computed independently for
+# each original matrix. Treat the pairs as non-independent observations.
+manir_group_overview <- function(first, second = NULL, metadata, field,
+ max_pairs = 100000L) {
+ if (is.null(metadata) || !is.data.frame(metadata) ||
+ length(field) != 1L || is.na(field) || !nzchar(field) ||
+ !field %in% names(metadata))
+ stop("Select a metadata column containing sample groups or batches.")
+ one <- replicate_summary(first, metadata, field, max_pairs = max_pairs)
+ names(one)[names(one) == "type"] <- "comparison"
+ one <- data.frame(matrix = "Matrix 1", one, check.names = FALSE)
+ if (!is.null(second)) {
+ two <- replicate_summary(second, metadata, field, max_pairs = max_pairs)
+ names(two)[names(two) == "type"] <- "comparison"
+ one <- rbind(one, data.frame(matrix = "Matrix 2", two,
+ check.names = FALSE))
+ }
+ rownames(one) <- NULL
+ one
+}
+
+manir_directional_association <- function(pairs, kind1 = "similarity",
+ kind2 = "similarity") {
+ d <- pairs$data
+ if (nrow(d) < 3L)
+ return(list(pearson = NA_real_, spearman = NA_real_, n = nrow(d)))
+ x <- if (identical(kind1, "distance")) -d$first else d$first
+ y <- if (identical(kind2, "distance")) -d$second else d$second
+ valid <- is.finite(x) & is.finite(y)
+ x <- x[valid]; y <- y[valid]
+ list(pearson = if (length(x) >= 3L && stats::sd(x) > 0 &&
+ stats::sd(y) > 0) stats::cor(x, y) else NA_real_,
+ spearman = if (length(unique(x)) > 1L && length(unique(y)) > 1L)
+ suppressWarnings(stats::cor(x, y, method = "spearman"))
+ else NA_real_, n = length(x))
+}
+
+manir_fmt <- function(value) {
+ if (length(value) != 1L || !is.finite(value)) return("not estimable")
+ format(signif(value, 4L), trim = TRUE)
+}
+
+# Markdown text export for an editable lab notebook. Only optional,
+# already-computed results are included. Expensive clustering/permutation
+# analyses are never started by the report download itself.
+manir_research_report <- function(dataset, pairs = NULL, group_table = NULL,
+ group_field = "", cluster = NULL,
+ comparable = FALSE,
+ question = "", notes = "",
+ max_pairs = 100000L) {
+ example <- identical(dataset$source, "synthetic example")
+ one <- if (!is.null(dataset$label1)) dataset$label1 else "Matrix 1"
+ two <- if (!is.null(dataset$label2)) dataset$label2 else "Matrix 2"
+ lines <- c("# MAniR analysis notes", "",
+ if (nzchar(trimws(question))) paste("**Research question:**",
+ trimws(question)) else NULL,
+ if (example) "**Teaching example: synthetic data, not biological evidence.**" else NULL,
+ "## Dataset", "",
+ paste("- Source:", dataset$source),
+ sprintf("- %s: %d isolates (%s)", one, nrow(dataset$first),
+ dataset$kind1),
+ if (!is.null(dataset$second))
+ sprintf("- %s: %d isolates (%s)", two, nrow(dataset$second),
+ dataset$kind2),
+ sprintf("- Sample matching: %s; clustering linkage: %s",
+ dataset$matching, dataset$linkage),
+ if (!is.null(dataset$metadata))
+ paste("- Metadata fields:", paste(names(dataset$metadata),
+ collapse = ", ")),
+ "")
+ if (!is.null(pairs)) {
+ stats <- manir_directional_association(pairs, dataset$kind1,
+ dataset$kind2)
+ lines <- c(lines, "## Pairwise relationship", "",
+ sprintf("- %s of %s unordered pairs evaluated; %s had values in both matrices.",
+ pairs$evaluated_pairs,
+ format(pairs$total_pairs, scientific = FALSE),
+ pairs$valid_pairs),
+ sprintf("- Direction-adjusted Pearson r: %s", manir_fmt(stats$pearson)),
+ sprintf("- Direction-adjusted Spearman rho: %s", manir_fmt(stats$spearman)),
+ if (isTRUE(pairs$sampled))
+ sprintf("- Reproducible sample: %s pairs (seed %s; requested limit %s).",
+ pairs$evaluated_pairs, pairs$seed, max_pairs),
+ if (isTRUE(comparable)) {
+ raw <- matrix_agreement(pairs)
+ c(sprintf("- Mean absolute difference (original units): %s",
+ manir_fmt(raw$mae)),
+ sprintf("- Root mean squared difference (original units): %s",
+ manir_fmt(raw$rmse)))
+ } else "- Raw differences omitted: measurement equivalence was not confirmed.",
+ "",
+ "These are descriptive measurements. Repeated isolate pairs are not independent observations.",
+ "")
+ }
+ if (!is.null(cluster)) {
+ lines <- c(lines, "## Cluster comparison", "",
+ sprintf("- Adjusted Rand index: %s", manir_fmt(cluster$ari)),
+ sprintf("- Adjusted Wallace, matrix 1 to 2: %s",
+ manir_fmt(cluster$adjusted_wallace_1_to_2)),
+ sprintf("- Adjusted Wallace, matrix 2 to 1: %s",
+ manir_fmt(cluster$adjusted_wallace_2_to_1)),
+ "Cluster labels are arbitrary; agreement is not a measure of typing accuracy.",
+ "")
+ }
+ if (!is.null(group_table) && nrow(group_table)) {
+ lines <- c(lines, "## Metadata groups", "",
+ paste("Selected field:", group_field), "",
+ "| Matrix | Pair category | Pairs | Median | Sampled |",
+ "| --- | --- | ---: | ---: | --- |",
+ vapply(seq_len(nrow(group_table)), function(i) {
+ x <- group_table[i, ]
+ sprintf("| %s | %s | %s | %s | %s |",
+ x$matrix, x$comparison, x$pairs,
+ manir_fmt(x$median), if (isTRUE(x$sampled)) "yes" else "no")
+ }, character(1L)),
+ "",
+ "Group summaries are descriptive. Pairwise values reuse isolates, and batch effects may be confounded.",
+ "")
+ }
+ lines <- c(lines, "## Researcher notes", "",
+ if (nzchar(trimws(notes))) notes else
+ "Record what the figures suggest, the limitations, and what you plan to check next.",
+ "", "## Reproducibility", "",
+ "- Export the original matrix CSV or RDS files alongside these notes.",
+ "- Record the selected metadata field, pair limit and version of MAniR.",
+ "- Figures may use independently normalized colors or raster previews; use original values for inference.")
+ lines
+}
From e8500644012d105ed81a221aa866ac6cd111552a Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:09:22 +0200
Subject: [PATCH 110/134] ui: create question-led analysis start screen with
five concrete research workflows
---
R/research_ui.R | 67 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 67 insertions(+)
create mode 100644 R/research_ui.R
diff --git a/R/research_ui.R b/R/research_ui.R
new file mode 100644
index 0000000..f7eab38
--- /dev/null
+++ b/R/research_ui.R
@@ -0,0 +1,67 @@
+# Task-oriented start screen. The analysis remains in the original Shiny tabs,
+# so researchers can switch between the guided route and all available tools.
+
+manir_question_card <- function(title, purpose, action_id, action_label,
+ requirement, next_step) {
+ shiny::div(class = "question-card",
+ shiny::div(class = "question-top",
+ shiny::h4(title),
+ shiny::span(class = "question-requirement", requirement)),
+ shiny::p(purpose),
+ shiny::p(class = "question-next", next_step),
+ shiny::actionButton(action_id, action_label, class = "btn-primary")
+ )
+}
+
+manir_research_start <- function() {
+ shiny::tagList(
+ shiny::div(class = "start-hero",
+ shiny::div(class = "start-kicker", "YOUR ANALYSIS"),
+ shiny::h3("What do you want to find out?"),
+ shiny::p("Choose a research question. MAniR opens the relevant analysis with a short explanation of what to look for.")
+ ),
+ shiny::uiOutput("research_status"),
+ shiny::div(class = "question-grid",
+ manir_question_card(
+ "Do two methods give similar results?",
+ "Examine whether pairs with high similarity in one matrix also have high similarity in another.",
+ "start_agreement", "Compare the methods",
+ "2 matrices",
+ "See the association statistics, then inspect the pairwise scatterplot."
+ ),
+ manir_question_card(
+ "Which isolate pairs deserve a closer look?",
+ "Find pairs that occupy very different positions in two methods and inspect their exact values.",
+ "start_pairs", "Find unusual pairs",
+ "2 matrices",
+ "Select an isolate pair, check both results and review its metadata."
+ ),
+ manir_question_card(
+ "Do both methods make the same groups?",
+ "Compare independently calculated clusters and identify isolates whose assignments differ.",
+ "start_cluster", "Compare clusters",
+ "2 matrices",
+ "Choose the number of clusters, then read the overlap table."
+ ),
+ manir_question_card(
+ "Are known groups or batches reflected in the data?",
+ "Compare within-group and between-group pairwise measurements using sample metadata.",
+ "start_groups", "Explore my groups",
+ "1 matrix + metadata",
+ "Select a group or batch field and examine both matrices separately."
+ ),
+ manir_question_card(
+ "I want to inspect my matrix.",
+ "Open the heatmaps, explore a region and check the exact value for a pair of isolates.",
+ "start_heatmap", "Explore heatmaps",
+ "1 matrix",
+ "Start with the first matrix; switch to the combined view if both are loaded."
+ )
+ ),
+ shiny::div(class = "start-footer",
+ shiny::strong("Keep a record of your analysis"),
+ shiny::p("When you have explored the results, use Export to save an editable research summary, the original data and your figures."),
+ shiny::actionButton("start_export", "Open exports", class = "btn-default")
+ )
+ )
+}
From 38b3deecce547cf184768377b028848c8b51f678 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:09:33 +0200
Subject: [PATCH 111/134] build: load task-based research UI and pure research
summary modules
---
app.R | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app.R b/app.R
index 7218bb3..8622ec4 100644
--- a/app.R
+++ b/app.R
@@ -12,8 +12,10 @@ if (!is.finite(max_upload_mb) || max_upload_mb < 1)
options(shiny.maxRequestSize = max_upload_mb * 1024^2)
source("R/matrix_io.R", local = TRUE)
source("R/matrix_analysis.R", local = TRUE)
+source("R/research_workflows.R", local = TRUE)
source("R/matrix_plot.R", local = TRUE)
source("R/example_data.R", local = TRUE)
+source("R/research_ui.R", local = TRUE)
source("ui.R", local = TRUE)
source("server.R", local = TRUE)
shiny::shinyApp(ui = ui, server = server)
From b6f33f27185e3ebd1f13ece4cb116652e2972334 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:10:21 +0200
Subject: [PATCH 112/134] ui: add researcher question cards, exact pair
inspector, group summaries and editable lab notes
---
ui.R | 35 ++++++++++++++++++++++++++++++++++-
1 file changed, 34 insertions(+), 1 deletion(-)
diff --git a/ui.R b/ui.R
index 3aacc41..2e8b8d4 100644
--- a/ui.R
+++ b/ui.R
@@ -78,6 +78,10 @@ ui <- shiny::fluidPage(
shiny::fileInput("metadata_file", "Metadata (optional)",
accept = c(".xlsx", ".csv", ".tsv", ".rds")),
shiny::uiOutput("metadata_sheet_ui"),
+ shiny::textInput("label1", "Short name for matrix 1", value = "Matrix 1",
+ placeholder = "e.g. ANI from WGS"),
+ shiny::textInput("label2", "Short name for matrix 2", value = "Matrix 2",
+ placeholder = "e.g. MALDI spectral similarity"),
shiny::fileInput("order_file", "Sample order (optional)",
accept = c(".txt", ".csv", ".tsv")),
shiny::selectInput("kind1", "Matrix 1 represents",
@@ -133,7 +137,7 @@ ui <- shiny::fluidPage(
)
),
shiny::div(class = "toolbar-field",
- shiny::selectInput("metadata_column", "Annotation",
+ shiny::selectInput("metadata_column", "Group / annotation",
choices = c("None" = ""))
),
shiny::conditionalPanel(
@@ -194,8 +198,10 @@ ui <- shiny::fluidPage(
shiny::uiOutput("active_analysis")
)
),
+ shiny::uiOutput("research_context"),
shiny::div(class = "results-shell",
shiny::tabsetPanel(id = "results_tab",
+ shiny::tabPanel("Start here", manir_research_start()),
shiny::tabPanel("Overview",
manir_guide(
"Start with the number of shared isolates and the relationship between the two matrices. Correlations show whether pairs that are high in one measurement also tend to be high in the other; they do not establish equivalence.",
@@ -250,6 +256,19 @@ ui <- shiny::fluidPage(
"Each dot is one distinct, unordered isolate pair. Its X position is the pair's value in matrix 1 and its Y position is the value for the same pair in matrix 2. Neither the diagonal nor mirrored duplicates are counted.",
"An upward pattern suggests positive association, while points far from the main pattern merit closer inspection. Different units are allowed here: compare ranks or correlations, not the raw distance from a one-to-one line. Pairwise observations share isolates, so they are not statistically independent."),
shiny::uiOutput("pairwise_intro"),
+ shiny::div(class = "pair-focus",
+ shiny::h4("Inspect a specific isolate pair"),
+ shiny::p(class = "small-help",
+ "Pick any two shared isolates to inspect exact values in both matrices, or choose one of the pairs with the largest rank differences."),
+ shiny::uiOutput("top_pairs_picker"),
+ shiny::div(class = "pair-selectors",
+ shiny::selectizeInput("inspect_isolate", "First isolate",
+ choices = NULL, options = list(placeholder = "Search isolate...")),
+ shiny::selectizeInput("inspect_partner", "Second isolate",
+ choices = NULL, options = list(placeholder = "Search second isolate..."))
+ ),
+ shiny::uiOutput("pair_inspection")
+ ),
shiny::div(class = "plot-frame",
shiny::plotOutput("scatter", height = "100%")
),
@@ -277,12 +296,26 @@ ui <- shiny::fluidPage(
manir_guide(
"Metadata describe each isolate, such as its group, specimen source or experimental batch. Select a categorical field in the sidebar to show a colored annotation track next to the heatmaps.",
"Metadata categories are not automatically cluster labels or validation truth. A batch-associated cluster may reflect an experimental effect and needs separate investigation. Numeric metadata should not be interpreted as categories unless intentionally converted."),
+ shiny::uiOutput("metadata_group_status"),
+ shiny::tableOutput("group_overview"),
+ shiny::h4("Sample metadata"),
shiny::tableOutput("metadata_preview"),
shiny::uiOutput("metadata_note")),
shiny::tabPanel("Export",
manir_guide(
"CSV and RDS preserve the original matrix values. PNG, PDF, SVG and interactive HTML preserve the displayed ordering and palette, but figure colors may be normalized or raster-sampled.",
"Plots from large datasets are representative previews, not complete value tables. Export the CSV or RDS alongside your figures and save the settings manifest to record clustering, color mapping, pair sampling and your R environment."),
+ shiny::h4("Research notebook"),
+ shiny::p(class = "small-help",
+ "Save an editable Markdown summary of the dataset and your selected analyses. It is a starting point for your own interpretation, not an automatic scientific conclusion."),
+ shiny::textInput("research_question", "Your research question (optional)",
+ placeholder = "e.g. Do MALDI-TOF similarities reflect ANI relationships?"),
+ shiny::textAreaInput("research_notes", "Your observations and next steps",
+ placeholder = "What do you notice? Which pairs need follow-up? What are the limitations?",
+ rows = 3, width = "100%"),
+ shiny::downloadButton("download_research_summary", "Download research notes (.md)",
+ class = "btn-primary"),
+ shiny::hr(),
shiny::uiOutput("difference_download_notice"),
shiny::downloadButton("download_first", "Matrix 1 CSV"),
shiny::downloadButton("download_second", "Matrix 2 CSV"),
From 333fef779ddea7294745b2116765f4b4d0de2b6b Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:11:09 +0200
Subject: [PATCH 113/134] feat: compute bounded within/between-group values for
separate descriptive plots
---
R/research_workflows.R | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/R/research_workflows.R b/R/research_workflows.R
index cfa2e9f..50c2d03 100644
--- a/R/research_workflows.R
+++ b/R/research_workflows.R
@@ -160,3 +160,28 @@ manir_research_report <- function(dataset, pairs = NULL, group_table = NULL,
"- Figures may use independently normalized colors or raster previews; use original values for inference.")
lines
}
+
+
+# Values for a descriptive group-distribution plot. Never feed these into a
+# standard two-independent-samples test: pairs within groups share isolates.
+manir_group_pairs <- function(matrix, metadata, field,
+ max_pairs = 20000L) {
+ if (is.null(metadata) || !is.data.frame(metadata) ||
+ length(field) != 1L || is.na(field) || !nzchar(field) ||
+ !field %in% names(metadata))
+ stop("Choose a valid metadata group or batch field.")
+ groups <- as.character(metadata[match(rownames(matrix),
+ rownames(metadata)), field])
+ ix <- upper_pair_indices(nrow(matrix), max_pairs = max_pairs)
+ value <- matrix[cbind(ix$i, ix$j)]
+ left <- groups[ix$i]
+ right <- groups[ix$j]
+ valid <- is.finite(value) & !is.na(left) & !is.na(right) &
+ nzchar(left) & nzchar(right)
+ list(data = data.frame(
+ group = ifelse(left[valid] == right[valid], "Within", "Between"),
+ value = value[valid]),
+ sampled = ix$sampled,
+ evaluated_pairs = length(value),
+ total_pairs = ix$total)
+}
From 7239d662bde69e033d64c31b6ffa4fb50dd534cc Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:11:45 +0200
Subject: [PATCH 114/134] feat: guide researchers from a dataset readiness
screen to question-specific results
---
server.R | 168 +++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 146 insertions(+), 22 deletions(-)
diff --git a/server.R b/server.R
index 0da3678..09aca56 100644
--- a/server.R
+++ b/server.R
@@ -45,6 +45,8 @@ server <- function(input, output, session) {
kind1 <- "similarity"
kind2 <- "similarity"
match_mode <- "strict"
+ label1 <- "ANI (synthetic)"
+ label2 <- "MALDI (synthetic)"
cluster_requested <- TRUE
linkage <- "complete"
shiny::incProgress(0.40)
@@ -52,6 +54,10 @@ server <- function(input, output, session) {
kind1 <- input$kind1
kind2 <- input$kind2
match_mode <- input$match_mode
+ label1 <- trimws(if (is.null(input$label1)) "Matrix 1" else input$label1)
+ label2 <- trimws(if (is.null(input$label2)) "Matrix 2" else input$label2)
+ if (!nzchar(label1)) label1 <- "Matrix 1"
+ if (!nzchar(label2)) label2 <- "Matrix 2"
cluster_requested <- isTRUE(input$cluster)
linkage <- input$linkage
f <- input$first_file
@@ -136,6 +142,7 @@ server <- function(input, output, session) {
is.null(explicit) &&
(nrow(a) > 2000L || (!is.null(b) && nrow(b) > 2000L)),
kind1 = kind1, kind2 = kind2,
+ label1 = label1, label2 = label2,
linkage = linkage)
}, error = function(e) {
shiny::showNotification(conditionMessage(e), type = "error",
@@ -157,17 +164,16 @@ server <- function(input, output, session) {
sprintf(" | %s in matrix 2",
format(nrow(d$second), big.mark = ","))))
})
- # After loading, open the result rather than leaving the researcher
- # at an empty Overview tab. Display-only controls then update it in place.
- show_loaded_heatmap <- function() {
- shiny::updateTabsetPanel(session, "results_tab", selected = "Heatmaps")
- shiny::updateTabsetPanel(session, "matrix_view", selected = "Matrix 1")
+ # Put the research question first. The user can jump directly to the
+ # relevant analysis without first learning the list of statistical tabs.
+ show_research_start <- function() {
+ shiny::updateTabsetPanel(session, "results_tab", selected = "Start here")
}
shiny::observeEvent(input$visualize, {
result <- analyze_input(example = FALSE)
if (!is.null(result)) {
loaded(result)
- show_loaded_heatmap()
+ show_research_start()
}
})
shiny::observeEvent(input$load_example, {
@@ -178,10 +184,12 @@ server <- function(input, output, session) {
shiny::updateCheckboxInput(session, "cluster", value = TRUE)
shiny::updateSelectInput(session, "linkage", selected = "complete")
shiny::updateNumericInput(session, "cluster_k", value = 3)
+ shiny::updateTextInput(session, "label1", value = "Matrix 1")
+ shiny::updateTextInput(session, "label2", value = "Matrix 2")
result <- analyze_input(example = TRUE)
if (!is.null(result)) {
loaded(result)
- show_loaded_heatmap()
+ show_research_start()
}
})
output$download_example <- shiny::downloadHandler(
@@ -189,23 +197,138 @@ server <- function(input, output, session) {
content = function(file) manir_write_example_workbook(file)
)
+ # Isolate searches use server-side Selectize so a 10k-isolate project
+ # does not inject tens of thousands of options into the browser.
+ shiny::observeEvent(loaded(), {
+ d <- loaded()
+ if (is.null(d)) return()
+ ids <- if (is.null(d$second)) colnames(d$first) else
+ intersect(colnames(d$first), colnames(d$second))
+ first_default <- if (identical(d$source, "synthetic example"))
+ "ISO_02" else ids[1L]
+ second_default <- if (identical(d$source, "synthetic example"))
+ "ISO_03" else ids[min(2L, length(ids))]
+ shiny::updateSelectizeInput(session, "inspect_isolate",
+ choices = ids, selected = first_default, server = TRUE)
+ shiny::updateSelectizeInput(session, "inspect_partner",
+ choices = ids, selected = second_default, server = TRUE)
+ })
+
data <- shiny::reactive({ shiny::req(loaded()); loaded() })
output$active_analysis <- shiny::renderUI({
- d <- data()
- if (identical(d$source, "synthetic example")) {
- return(shiny::p(class = "small-help",
- "Currently displayed: synthetic ANI-like and MALDI-like similarity matrices. This example always uses similarity inputs, even if a dropdown below is changed. Reload the example to reset the controls."))
+ d <- loaded()
+ if (is.null(d))
+ return(shiny::span(class = "small-help", "No data loaded"))
+ changed <- !identical(d$source, "synthetic example") &&
+ ((!is.null(input$kind1) && !identical(input$kind1, d$kind1)) ||
+ (!is.null(input$kind2) && !identical(input$kind2, d$kind2)) ||
+ (!is.null(input$linkage) && !identical(input$linkage, d$linkage)) ||
+ (!is.null(input$match_mode) && !identical(input$match_mode, d$matching)))
+ shiny::div(
+ shiny::span(class = "data-indicator",
+ sprintf("%s · %s isolates%s",
+ if (identical(d$source, "synthetic example")) "Teaching example"
+ else "Your data", format(nrow(d$first), big.mark = ","),
+ if (is.null(d$second)) "" else
+ sprintf(" · %s shared", length(intersect(colnames(d$first),
+ colnames(d$second)))))),
+ if (changed)
+ shiny::span(class = "settings-warning",
+ "Settings changed. Click Load and analyze to apply.")
+ )
+ })
+ current_goal <- shiny::reactiveVal(NULL)
+ output$research_context <- shiny::renderUI({
+ goal <- current_goal()
+ if (is.null(goal) || !identical(input$results_tab, goal$tab)) return(NULL)
+ shiny::div(class = "research-context",
+ shiny::strong(goal$title),
+ shiny::span(goal$hint),
+ shiny::actionButton("back_to_questions", "All questions",
+ class = "btn-link btn-sm")
+ )
+ })
+ shiny::observeEvent(input$back_to_questions, {
+ current_goal(NULL)
+ shiny::updateTabsetPanel(session, "results_tab", selected = "Start here")
+ })
+ open_question <- function(tab, title, hint, requires_two = FALSE,
+ requires_metadata = FALSE) {
+ d <- loaded()
+ if (is.null(d)) {
+ shiny::showNotification(
+ "Load the teaching example or upload a matrix first.",
+ type = "message", duration = 6)
+ return(invisible(NULL))
}
- changed <- (!is.null(input$kind1) && !identical(input$kind1, d$kind1)) ||
- (!is.null(input$kind2) && !identical(input$kind2, d$kind2)) ||
- (!is.null(input$linkage) && !identical(input$linkage, d$linkage))
- shiny::p(class = "small-help",
- sprintf("Currently displayed: %s (matrix 1), %s (matrix 2); %s linkage.%s",
- d$kind1, if (is.null(d$second)) "not loaded" else d$kind2,
- d$linkage,
- if (changed)
- " You have changed analysis settings. Click Load and analyze to apply them."
- else ""))
+ if (requires_two && is.null(d$second)) {
+ shiny::showNotification(
+ "This question needs two matrices for the same isolates. Add a second matrix and reload.",
+ type = "message", duration = 8)
+ return(invisible(NULL))
+ }
+ if (requires_metadata && is.null(d$metadata)) {
+ shiny::showNotification(
+ "This question needs sample metadata. Upload a metadata table with matching isolate IDs.",
+ type = "message", duration = 8)
+ return(invisible(NULL))
+ }
+ current_goal(list(tab = tab, title = title, hint = hint))
+ shiny::updateTabsetPanel(session, "results_tab", selected = tab)
+ }
+ shiny::observeEvent(input$start_agreement,
+ open_question("Overview", "Do the two methods agree?",
+ "Start with the correlation and inspect the scatterplot if individual pairs differ.",
+ requires_two = TRUE))
+ shiny::observeEvent(input$start_pairs,
+ open_question("Pairwise comparison", "Which isolate pairs need follow-up?",
+ "Select an unusual pair below to inspect its original values and sample groups.",
+ requires_two = TRUE))
+ shiny::observeEvent(input$start_cluster,
+ open_question("Cluster comparison", "Do the methods group isolates similarly?",
+ "Use the cluster-overlap table; change the number of clusters if justified.",
+ requires_two = TRUE))
+ shiny::observeEvent(input$start_groups,
+ open_question("Metadata", "Do sample groups or batches show a pattern?",
+ "Select the biological group or experimental batch in the toolbar.",
+ requires_metadata = TRUE))
+ shiny::observeEvent(input$start_heatmap, {
+ open_question("Heatmaps", "Explore the sample relationship matrix",
+ "Hover over cells for original values; try the combined view when two matrices are loaded.")
+ if (!is.null(loaded()))
+ shiny::updateTabsetPanel(session, "matrix_view", selected = "Matrix 1")
+ })
+ shiny::observeEvent(input$start_export,
+ open_question("Export", "Record and export your findings",
+ "Save editable notes along with original matrix values and figures."))
+
+ output$research_status <- shiny::renderUI({
+ d <- loaded()
+ if (is.null(d))
+ return(shiny::div(class = "start-status empty",
+ shiny::strong("First, load your data"),
+ shiny::p("Use the example button on the left to see a complete analysis, or upload your own matrix. Add a second matrix to compare methods and metadata to examine groups or batches.")))
+ n_shared <- if (is.null(d$second)) NULL else
+ length(intersect(colnames(d$first), colnames(d$second)))
+ shiny::div(class = "start-status ready",
+ shiny::strong(if (identical(d$source, "synthetic example"))
+ "Teaching example ready (fictional data)" else "Your dataset is ready"),
+ shiny::div(class = "start-facts",
+ shiny::span(sprintf("%s: %d isolates", d$label1, nrow(d$first))),
+ if (!is.null(d$second))
+ shiny::span(sprintf("%s: %d isolates; %d shared",
+ d$label2, nrow(d$second), n_shared)),
+ if (!is.null(d$metadata))
+ shiny::span(sprintf("Metadata: %s",
+ paste(names(d$metadata), collapse = ", ")))
+ ),
+ if (is.null(d$second))
+ shiny::p("You can explore the matrix now. For method agreement and pair discrepancies, upload a second matrix."),
+ if (is.null(d$metadata))
+ shiny::p("Add metadata if you want to explore known groups or possible batch patterns."),
+ if (d$clustering_skipped)
+ shiny::p("Clustering was skipped for a large dataset. Use an imported sample order or smaller subset for cluster comparisons.")
+ )
})
has_two <- shiny::reactive(!is.null(data()$second))
subset_if_needed <- function(m, ids) {
@@ -268,7 +391,8 @@ server <- function(input, output, session) {
}
pair_data <- shiny::reactive({
m <- shared()
- paired_values(m$first, m$second, max_pairs = input$max_pairs)
+ paired_values(m$first, m$second, max_pairs =
+ if (is.null(input$max_pairs)) 100000L else input$max_pairs)
})
output$overview <- shiny::renderUI({
From 77c96518b812b0f94965a0eeafb405afc28672c6 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:12:37 +0200
Subject: [PATCH 115/134] feat: inspect exact isolate pairs, highlight chosen
pairs and compare group distributions
---
server.R | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 155 insertions(+), 2 deletions(-)
diff --git a/server.R b/server.R
index 09aca56..b401ab8 100644
--- a/server.R
+++ b/server.R
@@ -612,6 +612,74 @@ server <- function(input, output, session) {
format(p$evaluated_pairs, big.mark = ","), p$seed)
else " All pairs are included."))
})
+ output$top_pairs_picker <- shiny::renderUI({
+ if (!has_two()) return(NULL)
+ d <- data()
+ top <- pair_rank_gaps(pair_data(), d$kind1, d$kind2, top = 20L)
+ if (!nrow(top)) return(NULL)
+ labels <- sprintf("%s / %s (rank gap %.3f)",
+ top$sample_1, top$sample_2, top$rank_gap)
+ shiny::selectInput("rank_pair", "Explore a pair with different rankings",
+ choices = c("Choose from the largest rank gaps" = "",
+ stats::setNames(as.character(seq_len(nrow(top))), labels)))
+ })
+ shiny::observeEvent(input$rank_pair, {
+ if (is.null(input$rank_pair) || !nzchar(input$rank_pair) ||
+ !has_two()) return()
+ d <- data()
+ top <- pair_rank_gaps(pair_data(), d$kind1, d$kind2, top = 20L)
+ ix <- suppressWarnings(as.integer(input$rank_pair))
+ if (is.na(ix) || ix < 1L || ix > nrow(top)) return()
+ shiny::updateSelectizeInput(session, "inspect_isolate",
+ selected = top$sample_1[ix])
+ shiny::updateSelectizeInput(session, "inspect_partner",
+ selected = top$sample_2[ix])
+ })
+ selected_pair <- shiny::reactive({
+ d <- data()
+ first <- input$inspect_isolate
+ second <- input$inspect_partner
+ shiny::req(length(first) == 1L, length(second) == 1L,
+ nzchar(first), nzchar(second), first != second)
+ manir_pair_inspection(d$first, d$second, first, second,
+ metadata = d$metadata, field = input$metadata_column,
+ comparable = can_difference())
+ })
+ output$pair_inspection <- shiny::renderUI({
+ d <- loaded()
+ if (is.null(d) || is.null(d$second))
+ return(shiny::p(class = "small-help",
+ "Load two matrices to inspect corresponding values for a pair."))
+ if (is.null(input$inspect_isolate) || is.null(input$inspect_partner) ||
+ !nzchar(input$inspect_isolate) || !nzchar(input$inspect_partner) ||
+ identical(input$inspect_isolate, input$inspect_partner))
+ return(shiny::p(class = "small-help",
+ "Choose two different shared isolates above."))
+ p <- selected_pair()
+ fmt <- function(x) if (length(x) != 1L || !is.finite(x))
+ "Missing" else format(signif(x, 6), trim = TRUE)
+ shiny::div(class = "pair-inspection-values",
+ shiny::div(shiny::span(d$label1),
+ shiny::strong(fmt(p$first))),
+ shiny::div(shiny::span(d$label2),
+ shiny::strong(fmt(p$second))),
+ if (!is.null(p$difference))
+ shiny::div(shiny::span("Difference, original units"),
+ shiny::strong(fmt(p$difference))),
+ if (nzchar(p$metadata_field) &&
+ (!is.na(p$group_1) || !is.na(p$group_2)))
+ shiny::p(class = "pair-annotation",
+ sprintf("%s: %s = %s; %s = %s",
+ p$metadata_field, p$isolate_1,
+ if (is.na(p$group_1)) "missing" else p$group_1,
+ p$isolate_2,
+ if (is.na(p$group_2)) "missing" else p$group_2)),
+ if (is.null(p$difference))
+ shiny::p(class = "small-help",
+ "Original values shown separately. A difference is omitted because equivalent measurement units have not been confirmed.")
+ )
+ })
+
output$scatter <- shiny::renderPlot({
shiny::req(has_two())
p <- pair_data()
@@ -638,6 +706,19 @@ server <- function(input, output, session) {
graphics::points(d$first[highlighted], d$second[highlighted],
pch = 1L, cex = 1.65, lwd = 1.5, col = "#c45a35")
}
+ if (length(input$inspect_isolate) == 1L &&
+ length(input$inspect_partner) == 1L &&
+ nzchar(input$inspect_isolate) && nzchar(input$inspect_partner) &&
+ !identical(input$inspect_isolate, input$inspect_partner)) {
+ chosen <- tryCatch(selected_pair(), error = function(e) NULL)
+ if (!is.null(chosen) && is.finite(chosen$first) &&
+ is.finite(chosen$second)) {
+ graphics::points(chosen$first, chosen$second, pch = 21L,
+ bg = "#fac06e", col = "#803a1d", cex = 2, lwd = 1.4)
+ graphics::legend("topright", legend = "Selected pair",
+ pch = 21L, pt.bg = "#fac06e", bty = "n", cex = 0.85)
+ }
+ }
})
output$rank_gaps <- shiny::renderTable({
shiny::req(has_two())
@@ -677,7 +758,7 @@ server <- function(input, output, session) {
shiny::req(has_two())
d <- data()
shiny::p(class = "section-note",
- sprintf("Clustering %d shared isolates independently in each matrix, using %d clusters and %s linkage. Change k in the sidebar to explore the stability of agreement.",
+ sprintf("Clustering %d shared isolates independently in each matrix, using %d clusters and %s linkage. Change k in the toolbar to see how grouping agreement changes.",
length(shared()$ids), input$cluster_k, d$linkage))
})
output$cluster_summary <- shiny::renderTable({
@@ -701,6 +782,78 @@ server <- function(input, output, session) {
names(assignment) <- c("Isolate", "Matrix 1 cluster", "Matrix 2 cluster")
assignment
}, rownames = FALSE)
+ group_summary <- shiny::reactive({
+ d <- data()
+ shiny::req(!is.null(d$metadata), length(input$metadata_column) == 1L,
+ nzchar(input$metadata_column))
+ manir_group_overview(d$first, d$second, d$metadata,
+ input$metadata_column,
+ max_pairs = if (is.null(input$max_pairs)) 100000L else input$max_pairs)
+ })
+ output$metadata_group_status <- shiny::renderUI({
+ d <- data()
+ if (is.null(d$metadata))
+ return(shiny::div(class = "start-status empty",
+ shiny::strong("Add sample metadata to investigate groups"),
+ shiny::p("Upload a file with isolate IDs and a biological group, replicate or batch column, then reload the data.")))
+ if (is.null(input$metadata_column) || !nzchar(input$metadata_column))
+ return(shiny::div(class = "start-status empty",
+ shiny::strong("Choose a group or batch field"),
+ shiny::p(sprintf("Select one of these fields in the toolbar: %s.",
+ paste(names(d$metadata), collapse = ", ")))))
+ shiny::div(class = "start-status ready",
+ shiny::strong(sprintf("Exploring '%s'", input$metadata_column)),
+ shiny::p("These are descriptive pair summaries. Isolate pairs are reused, so the table and plots do not provide independent-sample p-values."))
+ })
+ output$group_overview <- shiny::renderTable({
+ x <- group_summary()
+ names(x) <- c("Matrix", "Pair category", "Pairs", "Mean",
+ "Median", "Sampled")
+ x
+ }, digits = 5, rownames = FALSE)
+ output$group_plot_ui <- shiny::renderUI({
+ d <- data()
+ if (is.null(d$metadata) || is.null(input$metadata_column) ||
+ !nzchar(input$metadata_column)) return(NULL)
+ shiny::tagList(
+ shiny::h4("Distribution of pairwise measurements"),
+ shiny::p(class = "small-help",
+ "Each method uses its own original numerical scale. Boxplots show the spread of sampled or full pair values within and between groups, not independent biological replicates."),
+ shiny::plotOutput("group_distributions", height = "315px")
+ )
+ })
+ output$group_distributions <- shiny::renderPlot({
+ d <- data()
+ shiny::req(d$metadata, length(input$metadata_column) == 1L,
+ nzchar(input$metadata_column))
+ matrices <- list(d$first)
+ labels <- c(d$label1)
+ if (!is.null(d$second)) {
+ matrices <- c(matrices, list(d$second))
+ labels <- c(labels, d$label2)
+ }
+ n <- length(matrices)
+ old <- graphics::par(mfrow = c(1L, n), mar = c(5, 4.5, 3.4, 1),
+ oma = c(0, 0, 0, 0))
+ on.exit(graphics::par(old), add = TRUE)
+ for (i in seq_len(n)) {
+ gp <- manir_group_pairs(matrices[[i]], d$metadata,
+ input$metadata_column)
+ if (nrow(gp$data) && length(unique(gp$data$group)) > 1L) {
+ graphics::boxplot(value ~ group, data = gp$data,
+ main = labels[i], ylab = "Original matrix value", xlab = "Pair category",
+ col = c("#d4e9f4", "#b7d3d9"), outline = FALSE)
+ graphics::mtext(if (gp$sampled) "Representative sample of pairs"
+ else "All available pairs",
+ side = 3, line = 0.15, cex = 0.65)
+ } else {
+ graphics::plot.new()
+ graphics::title(main = labels[i])
+ graphics::text(.5, .5,
+ "At least one within- and one between-group pair are needed.")
+ }
+ }
+ }, res = 110)
output$metadata_preview <- shiny::renderTable({
shiny::req(data()$metadata)
utils::head(data()$metadata, 100L)
@@ -734,7 +887,7 @@ server <- function(input, output, session) {
})
output$metadata_note <- shiny::renderUI({
shiny::req(data()$metadata)
- shiny::p("Showing up to 100 metadata rows. Choose a categorical metadata column in the sidebar to compare within-group and between-group matrix values.")
+ shiny::p("Showing up to 100 metadata rows. Choose a categorical metadata field in the toolbar to compare within-group and between-group measurements.")
})
mantel_result <- shiny::eventReactive(input$run_mantel, {
From 74b89d57f7fd33a80247cdd0319540d958205384 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:12:56 +0200
Subject: [PATCH 116/134] ui: show group distribution plots and optional
cluster calculations in research notes
---
ui.R | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/ui.R b/ui.R
index 2e8b8d4..ad2a18e 100644
--- a/ui.R
+++ b/ui.R
@@ -298,6 +298,7 @@ ui <- shiny::fluidPage(
"Metadata categories are not automatically cluster labels or validation truth. A batch-associated cluster may reflect an experimental effect and needs separate investigation. Numeric metadata should not be interpreted as categories unless intentionally converted."),
shiny::uiOutput("metadata_group_status"),
shiny::tableOutput("group_overview"),
+ shiny::uiOutput("group_plot_ui"),
shiny::h4("Sample metadata"),
shiny::tableOutput("metadata_preview"),
shiny::uiOutput("metadata_note")),
@@ -313,6 +314,9 @@ ui <- shiny::fluidPage(
shiny::textAreaInput("research_notes", "Your observations and next steps",
placeholder = "What do you notice? Which pairs need follow-up? What are the limitations?",
rows = 3, width = "100%"),
+ shiny::checkboxInput("report_clusters",
+ "Include cluster statistics (calculate if needed; at most 2,000 isolates)",
+ value = FALSE),
shiny::downloadButton("download_research_summary", "Download research notes (.md)",
class = "btn-primary"),
shiny::hr(),
From 6a3cd89794549627eb6b629de7dac530b4c83dfe Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:13:12 +0200
Subject: [PATCH 117/134] feat: download editable analysis notes with source
values, assumptions and optional analyses
---
server.R | 50 ++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 44 insertions(+), 6 deletions(-)
diff --git a/server.R b/server.R
index b401ab8..81aa645 100644
--- a/server.R
+++ b/server.R
@@ -427,15 +427,23 @@ server <- function(input, output, session) {
output$agreement <- shiny::renderTable({
shiny::req(has_two())
pp <- pair_data()
- stats <- matrix_agreement(pp)
+ d <- data()
+ stats <- manir_directional_association(pp, d$kind1, d$kind2)
+ raw <- if (can_difference()) matrix_agreement(pp) else NULL
+ adjusted <- identical(d$kind1, "distance") ||
+ identical(d$kind2, "distance")
data.frame(
- Measure = c("Valid isolate pairs", "Pearson r", "Spearman rho",
+ Measure = c("Valid isolate pairs",
+ if (adjusted) "Pearson r (distance direction adjusted)"
+ else "Pearson r",
+ if (adjusted) "Spearman rho (distance direction adjusted)"
+ else "Spearman rho",
if (can_difference()) c("Mean absolute difference",
- "Root mean squared difference")),
+ "Root mean squared difference")),
Value = c(stats$n, signif(stats$pearson, 5),
signif(stats$spearman, 5),
if (can_difference())
- c(signif(stats$mae, 5), signif(stats$rmse, 5)))
+ c(signif(raw$mae, 5), signif(raw$rmse, 5)))
)
})
output$mantel_note <- shiny::renderUI({
@@ -905,6 +913,34 @@ server <- function(input, output, session) {
r$observed, r$p_two_sided, r$permutations, r$seed))
})
+ output$download_research_summary <- shiny::downloadHandler(
+ filename = function() "MAniR_research_notes.md",
+ content = function(file) {
+ d <- data()
+ max_pairs <- if (is.null(input$max_pairs)) 100000L else input$max_pairs
+ pairs <- if (is.null(d$second)) NULL else pair_data()
+ field <- if (is.null(input$metadata_column)) "" else input$metadata_column
+ groups <- if (!is.null(d$metadata) && nzchar(field))
+ group_summary() else NULL
+ clusters <- NULL
+ if (isTRUE(input$report_clusters) && !is.null(d$second)) {
+ m <- shared()
+ k <- if (is.null(input$cluster_k)) 3L else as.integer(input$cluster_k)
+ if (nrow(m$first) <= 2000L && k >= 2L && k < nrow(m$first))
+ clusters <- concordance()
+ else
+ shiny::showNotification(
+ "Cluster statistics were omitted: the dataset or k is outside the supported limits.",
+ type = "warning", duration = 7)
+ }
+ question <- if (is.null(input$research_question)) "" else input$research_question
+ notes <- if (is.null(input$research_notes)) "" else input$research_notes
+ text <- manir_research_report(d, pairs = pairs, group_table = groups,
+ group_field = field, cluster = clusters, comparable = can_difference(),
+ question = question, notes = notes, max_pairs = max_pairs)
+ writeLines(text, con = file, useBytes = TRUE)
+ }
+ )
output$download_first <- shiny::downloadHandler(
filename = function() "MAniR_matrix1.csv",
content = function(file) write_matrix_csv(data()$first, file))
@@ -989,8 +1025,10 @@ server <- function(input, output, session) {
d <- data()
writeLines(c(
paste("MAniR generated:", Sys.time()),
- paste("First matrix:", nrow(d$first), "isolates"),
- paste("Second matrix:", if (is.null(d$second)) "None" else nrow(d$second)),
+ paste("First matrix:", d$label1, nrow(d$first), "isolates"),
+ paste("Second matrix:",
+ if (is.null(d$second)) "None" else
+ paste(d$label2, nrow(d$second), "isolates")),
paste("Matrix kinds:", d$kind1, d$kind2),
paste("Sample matching:", d$matching),
paste("Cluster requested:", input$cluster),
From 27b393179f6d14e0f02d08b53f68bacc1290b882 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:13:38 +0200
Subject: [PATCH 118/134] style: make research questions, exact pair drilldown
and data readiness clear in one viewport
---
www/workspace.css | 78 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 78 insertions(+)
diff --git a/www/workspace.css b/www/workspace.css
index 206b9f4..03178d4 100644
--- a/www/workspace.css
+++ b/www/workspace.css
@@ -191,3 +191,81 @@ body { background: var(--manir-bg); color: var(--manir-ink); }
.results-shell .tab-content { padding: 8px 10px 18px; }
.results-shell .reading-guide .guide-preview { display: none; }
}
+
+
+/* Research-question-first workspace. All panels scroll inside the existing
+ * result area, with no extra page-level scrollbar on ordinary desktops. */
+.start-hero { padding: 10px 0 6px; max-width: 840px; }
+.start-kicker { color: #45758c; font-size: 11px; font-weight: 750;
+ text-transform: uppercase; letter-spacing: .12em; }
+.start-hero h3 { margin: 4px 0 7px; font-size: 23px; font-weight: 680;
+ letter-spacing: -.025em; }
+.start-hero p { font-size: 13px; max-width: 85ch; color: #597083; }
+.start-status { border-radius: 8px; border: 1px solid #d7e3e9;
+ padding: 13px 16px; margin: 7px 0 15px;
+ max-width: 100%; }
+.start-status.ready { background: #f0f8f7; border-color: #c6e1d9; }
+.start-status.empty { background: #f4f7fa; }
+.start-status strong { font-size: 14px; color: #254960; }
+.start-status p { font-size: 12px; margin: 6px 0 0; color: #5d7584; }
+.start-facts { display: flex; gap: 8px 18px; flex-wrap: wrap; margin-top: 9px; }
+.start-facts span { font-size: 12px; line-height: 1.4; border-radius: 5px;
+ background: white; padding: 5px 9px;
+ border: 1px solid #e0e9e7; }
+.question-grid { display: grid; grid-template-columns: repeat(2, minmax(250px, 1fr));
+ gap: 12px; padding: 2px 1px 12px; max-width: 1140px; }
+.question-card { border: 1px solid #dce6ec; border-radius: 9px;
+ background: #fff; padding: 14px 16px;
+ display: flex; flex-direction: column; min-width: 0;
+ transition: border-color .12s, box-shadow .12s; }
+.question-card:hover { border-color: #a7c6d3; box-shadow: 0 2px 9px rgba(36, 79, 103, .08); }
+.question-top { display: flex; flex-wrap: wrap; align-items: start;
+ justify-content: space-between; gap: 6px; }
+.question-card h4 { font-size: 16px; font-weight: 650; margin: 0 0 8px; max-width: 33ch; }
+.question-requirement { font-size: 10px; border-radius: 10px;
+ padding: 3px 7px; color: #466b7d; background: #edf4f8;
+ white-space: nowrap; }
+.question-card p { font-size: 12px; color: #50697b; margin: 0 0 9px; }
+.question-card .question-next { margin-top: auto; font-size: 11px;
+ color: #627e8f; padding: 5px 0; }
+.question-card .btn { margin-top: 5px; margin-bottom: 0;
+ align-self: flex-start; font-size: 12px; }
+.start-footer { border-top: 1px solid var(--manir-border); margin: 6px 0 20px;
+ padding: 14px 0 0; font-size: 12px; max-width: 850px; }
+.start-footer p { margin: 5px 0 8px; color: #607b8e; }
+.start-footer .btn { font-size: 12px; }
+.research-context { display: flex; align-items: center; gap: 8px 16px;
+ flex-wrap: wrap; background: #eef7fa;
+ padding: 7px 18px; border-bottom: 1px solid #d4e8ed;
+ font-size: 12px; flex: 0 0 auto; }
+.research-context strong { color: #315771; font-size: 12px; }
+.research-context span { color: #5e798c; flex: 1 1 230px; }
+.research-context .btn { margin: 0; padding: 2px 7px; }
+.data-indicator { font-weight: 600; color: #37647e; font-size: 11px; }
+.settings-warning { display: block; color: #975e13; font-size: 11px; }
+.pair-focus { padding: 12px 15px; background: #f4f8fa;
+ border: 1px solid #d7e7ec; border-radius: 8px; margin: 12px 0; }
+.pair-focus h4 { font-size: 15px; margin: 1px 0 6px; }
+.pair-focus p { margin-bottom: 7px; }
+.pair-focus .shiny-input-container { margin-bottom: 6px; }
+.pair-selectors { display: flex; flex-wrap: wrap; gap: 8px 14px;
+ margin: 6px 0 9px; }
+.pair-selectors > .form-group { flex: 1 1 180px; max-width: 310px; }
+.pair-selectors > .form-group label { font-size: 11px; }
+.pair-inspection-values { display: flex; align-items: center; flex-wrap: wrap;
+ gap: 8px 20px; padding-top: 7px;
+ border-top: 1px solid #d9e8ed; }
+.pair-inspection-values > div { display: flex; flex-direction: column; }
+.pair-inspection-values > div > span { color: #627d8f; font-size: 11px; }
+.pair-inspection-values > div > strong { color: #1f536d; font-size: 19px; }
+.pair-inspection-values p { flex-basis: 100%; font-size: 11px; margin: 0; }
+.pair-inspection-values .pair-annotation { color: #385f78; }
+@media (max-width: 1240px) {
+ .question-grid { grid-template-columns: repeat(2, minmax(210px, 1fr)); }
+ .question-card { padding: 12px; }
+}
+@media (max-width: 800px) {
+ .question-grid { grid-template-columns: 1fr; }
+ .pair-selectors > .form-group { max-width: none; }
+ .start-hero h3 { font-size: 20px; }
+}
From 9c723e00a8a5356e6344e8af23c47ecf89e7ef16 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:14:12 +0200
Subject: [PATCH 119/134] test: cover research questions, exact pair values,
grouped comparisons and research notes
---
tests/run_tests.R | 96 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 96 insertions(+)
diff --git a/tests/run_tests.R b/tests/run_tests.R
index 43cc3b4..442916c 100644
--- a/tests/run_tests.R
+++ b/tests/run_tests.R
@@ -3,6 +3,8 @@ source("R/matrix_io.R")
source("R/matrix_analysis.R")
source("R/matrix_plot.R")
source("R/example_data.R")
+source("R/research_workflows.R")
+source("R/research_ui.R")
expect_error <- function(expr, pattern = NULL) {
e <- tryCatch({force(expr); NULL}, error = function(e) conditionMessage(e))
@@ -298,6 +300,100 @@ test("metadata colors keep their meaning across both matrix orders", {
logical(1L))))
})
+
+test("research question choices are concrete and available on the start page", {
+ source("ui.R", local = TRUE)
+ html <- htmltools::renderTags(ui)$html
+ for (text in c("Start here", "What do you want to find out?",
+ "Do two methods give similar results?",
+ "Which isolate pairs deserve a closer look?",
+ "Do both methods make the same groups?",
+ "Are known groups or batches reflected in the data?",
+ "I want to inspect my matrix.",
+ "inspect_isolate", "inspect_partner",
+ "Download research notes", "group_plot_ui")) {
+ if (!grepl(text, html, fixed = TRUE))
+ stop("Missing research workflow control or help: ", text)
+ }
+ for (id in c("start_agreement", "start_pairs", "start_cluster",
+ "start_groups", "start_heatmap", "start_export",
+ "inspect_isolate", "inspect_partner", "research_question",
+ "research_notes")) {
+ occurrences <- gregexpr(paste0('id="', id, '"'), html,
+ fixed = TRUE)[[1L]]
+ stopifnot(length(occurrences) == 1L, occurrences[1L] != -1L)
+ }
+})
+
+test("pair inspection preserves exact values and distinguishes incomparable units", {
+ demo <- manir_load_example()
+ p <- manir_pair_inspection(demo$first, demo$second, "ISO_02", "ISO_03",
+ demo$metadata, "group")
+ stopifnot(isTRUE(all.equal(p$first, demo$first["ISO_02", "ISO_03"])),
+ isTRUE(all.equal(p$second, .52)),
+ is.null(p$difference), identical(p$group_1, "Group_A"),
+ identical(p$group_2, "Group_A"))
+ same <- manir_pair_inspection(demo$first, demo$first, "ISO_02", "ISO_03",
+ comparable = TRUE)
+ stopifnot(identical(same$difference, 0))
+ expect_error(manir_pair_inspection(demo$first, demo$second,
+ "ISO_02", "ISO_02"), "different")
+ expect_error(manir_pair_inspection(demo$first, demo$second,
+ "not_an_isolate", "ISO_02"), "present")
+})
+
+test("within- and between-group comparison retains original scales and sample accounting", {
+ demo <- manir_load_example()
+ result <- manir_group_overview(demo$first, demo$second,
+ demo$metadata, "group")
+ stopifnot(nrow(result) == 4L,
+ identical(unique(result$matrix), c("Matrix 1", "Matrix 2")),
+ sum(result$pairs[result$matrix == "Matrix 1"]) == 66L,
+ sum(result$pairs[result$matrix == "Matrix 2"]) == 66L)
+ plot_values <- manir_group_pairs(demo$first, demo$metadata, "group")
+ stopifnot(nrow(plot_values$data) == 66L,
+ !plot_values$sampled,
+ identical(sort(unique(plot_values$data$group)),
+ c("Between", "Within")))
+ expect_error(manir_group_overview(demo$first, metadata = demo$metadata,
+ field = "missing"), "Select")
+})
+
+test("direction-adjusted association treats distances as distances", {
+ demo <- manir_load_example()
+ pair <- paired_values(demo$first, demo$second)
+ related <- manir_directional_association(pair, "similarity", "similarity")
+ opposite <- manir_directional_association(pair, "distance", "similarity")
+ stopifnot(isTRUE(all.equal(related$pearson, -opposite$pearson)),
+ isTRUE(all.equal(related$spearman, -opposite$spearman)),
+ related$n == 66L)
+})
+
+test("research notes are editable, truthful and contain no mixed-scale difference", {
+ demo <- manir_load_example()
+ dataset <- c(demo, list(source = "synthetic example",
+ matching = "strict", linkage = "complete",
+ kind1 = "similarity", kind2 = "similarity",
+ label1 = "ANI (synthetic)", label2 = "MALDI (synthetic)"))
+ pairs <- paired_values(demo$first, demo$second)
+ groups <- manir_group_overview(demo$first, demo$second,
+ demo$metadata, "group")
+ notes <- manir_research_report(dataset, pairs, groups, "group",
+ comparable = FALSE,
+ question = "Do these methods describe similar relationships?",
+ notes = "Recheck the discordant pair with original data.")
+ joined <- paste(notes, collapse = "\n")
+ stopifnot(grepl("SYNTHETIC", joined),
+ grepl("Spearman", joined),
+ grepl("Recheck the discordant pair", joined),
+ grepl("Raw differences omitted", joined),
+ !grepl("Mean absolute difference", joined))
+ single <- dataset
+ single$second <- NULL
+ one <- manir_research_report(single)
+ stopifnot(!grepl("Pairwise relationship", paste(one, collapse = "\n")))
+})
+
test("workspace separates scrolling inputs from persistent live controls", {
source("ui.R", local = TRUE)
rendered <- htmltools::renderTags(ui)
From 7b81e2172e2236053680b89c9482f058b1950747 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:14:30 +0200
Subject: [PATCH 120/134] test: exercise question navigation, missing-data
guidance and pair/group drilldowns
---
tests/test_shiny.R | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/tests/test_shiny.R b/tests/test_shiny.R
index af9e647..62f8144 100644
--- a/tests/test_shiny.R
+++ b/tests/test_shiny.R
@@ -3,6 +3,8 @@ source("R/matrix_io.R")
source("R/matrix_analysis.R")
source("R/matrix_plot.R")
source("R/example_data.R")
+source("R/research_workflows.R")
+source("R/research_ui.R")
source("server.R")
ids <- c("001", "002", "003", "004")
@@ -32,6 +34,12 @@ shiny::testServer(server, {
# A palette change should affect only rendering, not the loaded matrix.
session$setInputs(palette = "Viridis")
stopifnot(identical(loaded()$first, loaded_data$first))
+ session$setInputs(start_pairs = 1)
+ stopifnot(is.null(current_goal()))
+ session$setInputs(start_heatmap = 1)
+ stopifnot(identical(current_goal()$tab, "Heatmaps"))
+ session$setInputs(start_groups = 1)
+ stopifnot(identical(current_goal()$tab, "Heatmaps"))
})
shiny::testServer(server, {
@@ -50,6 +58,23 @@ shiny::testServer(server, {
# subtracted or exported as a misleading difference matrix.
session$setInputs(comparable_scales = TRUE)
stopifnot(!can_difference())
+ session$setInputs(start_agreement = 1)
+ stopifnot(identical(current_goal()$tab, "Overview"))
+ session$setInputs(start_pairs = 1)
+ stopifnot(identical(current_goal()$tab, "Pairwise comparison"))
+ session$setInputs(inspect_isolate = "ISO_02", inspect_partner = "ISO_03")
+ exact <- selected_pair()
+ stopifnot(abs(exact$second - 0.52) < 1e-12,
+ is.null(exact$difference))
+ session$setInputs(metadata_column = "group", start_groups = 1)
+ stopifnot(identical(current_goal()$tab, "Metadata"),
+ nrow(group_summary()) == 4L)
+ session$setInputs(start_cluster = 1)
+ stopifnot(identical(current_goal()$tab, "Cluster comparison"))
+ session$setInputs(start_export = 1)
+ stopifnot(identical(current_goal()$tab, "Export"))
+ session$setInputs(back_to_questions = 1)
+ stopifnot(is.null(current_goal()))
# An ordinary upload after the example must replace it without carrying
# across the example metadata or second matrix.
session$setInputs(first_file = file_info, visualize = 1)
From c2c32dc36ac48d4e176a0da0cf8146ce7224121c Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:15:01 +0200
Subject: [PATCH 121/134] ui: add interpretation cues and direct next-step
action to numeric comparison tabs
---
ui.R | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/ui.R b/ui.R
index ad2a18e..097c03b 100644
--- a/ui.R
+++ b/ui.R
@@ -207,8 +207,11 @@ ui <- shiny::fluidPage(
"Start with the number of shared isolates and the relationship between the two matrices. Correlations show whether pairs that are high in one measurement also tend to be high in the other; they do not establish equivalence.",
"Pearson's r summarizes a linear relationship. Spearman's rho compares ranks and can detect a monotonic relationship on different scales. The same isolate appears in several pairs, so ordinary independent-observation p-values would be inappropriate. Use the optional Mantel permutation test for matrix association."),
shiny::uiOutput("overview"),
+ shiny::uiOutput("overview_insight"),
shiny::h4("Association between matrices"),
shiny::tableOutput("agreement"),
+ shiny::actionButton("overview_open_pairs",
+ "Inspect the pairs behind these numbers", class = "btn-default"),
shiny::uiOutput("mantel_note"),
shiny::verbatimTextOutput("mantel_result"),
shiny::h4("Within- and between-group values"),
@@ -284,6 +287,7 @@ ui <- shiny::fluidPage(
"Each matrix is independently clustered and cut into the same chosen number of groups (k). The statistics compare which isolate pairs are grouped together, regardless of the arbitrary cluster numbers.",
"Adjusted Rand index (ARI) equals 1 for identical partitions and is near 0 for chance-level agreement under its adjustment model. Adjusted Wallace is directional: matrix 1 to matrix 2 asks whether pairs grouped together in the first remain together in the second, adjusted for chance. Reverse the direction for the other value. These numbers describe agreement, not biological accuracy or a probability that either method is correct."),
shiny::uiOutput("cluster_intro"),
+ shiny::uiOutput("cluster_insight"),
shiny::tableOutput("cluster_summary"),
shiny::h4("How the clusters overlap"),
shiny::tableOutput("cluster_overlap"),
From 5dbfc72cb0fb45eb4ff06771b32509ed40f80b7d Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:15:23 +0200
Subject: [PATCH 122/134] feat: give researchers numerical context and
one-click follow-up from result summaries
---
server.R | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 58 insertions(+), 5 deletions(-)
diff --git a/server.R b/server.R
index 81aa645..57398e1 100644
--- a/server.R
+++ b/server.R
@@ -274,6 +274,9 @@ server <- function(input, output, session) {
return(invisible(NULL))
}
current_goal(list(tab = tab, title = title, hint = hint))
+ if (is.null(input$research_question) ||
+ !nzchar(trimws(input$research_question)))
+ shiny::updateTextInput(session, "research_question", value = title)
shiny::updateTabsetPanel(session, "results_tab", selected = tab)
}
shiny::observeEvent(input$start_agreement,
@@ -284,6 +287,10 @@ server <- function(input, output, session) {
open_question("Pairwise comparison", "Which isolate pairs need follow-up?",
"Select an unusual pair below to inspect its original values and sample groups.",
requires_two = TRUE))
+ shiny::observeEvent(input$overview_open_pairs,
+ open_question("Pairwise comparison", "Which pairs explain the matrix association?",
+ "The pair inspector uses exact original values, including pairs that were not in a scatterplot sample.",
+ requires_two = TRUE))
shiny::observeEvent(input$start_cluster,
open_question("Cluster comparison", "Do the methods group isolates similarly?",
"Use the cluster-overlap table; change the number of clusters if justified.",
@@ -324,6 +331,12 @@ server <- function(input, output, session) {
),
if (is.null(d$second))
shiny::p("You can explore the matrix now. For method agreement and pair discrepancies, upload a second matrix."),
+ if (!is.null(d$second) &&
+ length(intersect(colnames(d$first), colnames(d$second))) <
+ max(nrow(d$first), nrow(d$second)))
+ shiny::p(sprintf(
+ "Only %d isolates are shared; the individual heatmaps still contain the unmatched samples.",
+ n_shared)),
if (is.null(d$metadata))
shiny::p("Add metadata if you want to explore known groups or possible batch patterns."),
if (d$clustering_skipped)
@@ -424,6 +437,27 @@ server <- function(input, output, session) {
else "Direct differences are hidden until you confirm that the two matrices measure the same quantity in the same units.")
)
})
+ output$overview_insight <- shiny::renderUI({
+ d <- data()
+ if (is.null(d$second))
+ return(shiny::p(class = "section-note",
+ "With one matrix, explore the heatmap or upload a second matrix to compare methods."))
+ pairs <- pair_data()
+ stats <- manir_directional_association(pairs, d$kind1, d$kind2)
+ if (!is.finite(stats$spearman))
+ return(shiny::div(class = "start-status empty",
+ shiny::strong("Rank correlation is not estimable"),
+ shiny::p("The values may be constant or there may be too few usable isolate pairs. Inspect the original matrices.")))
+ shiny::div(class = "start-status ready",
+ shiny::strong(sprintf("For %s vs %s, Spearman rho = %s",
+ d$label1, d$label2, manir_fmt(stats$spearman))),
+ shiny::p(sprintf(
+ "Calculated from %s unordered isolate pairs%s. Values for distance matrices are reversed in this summary so that a larger oriented value always means greater similarity.",
+ format(stats$n, big.mark = ","),
+ if (pairs$sampled) " in a reproducible subset" else "")),
+ shiny::p("This describes the ranking of pairs, not whether either method is biologically correct. Check individual discrepancies and experimental metadata next.")
+ )
+ })
output$agreement <- shiny::renderTable({
shiny::req(has_two())
pp <- pair_data()
@@ -696,10 +730,10 @@ server <- function(input, output, session) {
"At least two nonmissing sample pairs are needed."))
current <- data()
first_label <- if (identical(current$source, "synthetic example"))
- "Illustrative ANI (%)" else paste("Matrix 1 (", current$kind1, ")", sep = "")
+ "Illustrative ANI (%)" else sprintf("%s (%s)", current$label1, current$kind1)
second_label <- if (identical(current$source, "synthetic example"))
"Illustrative MALDI similarity (0–1)" else
- paste("Matrix 2 (", current$kind2, ")", sep = "")
+ sprintf("%s (%s)", current$label2, current$kind2)
graphics::plot(d$first, d$second, pch = 19L,
col = grDevices::adjustcolor("#305f86", alpha.f = 0.58),
cex = 0.9, xlab = first_label, ylab = second_label,
@@ -762,6 +796,22 @@ server <- function(input, output, session) {
kind_b = data()$kind2, k = input$cluster_k,
linkage = data()$linkage)
})
+ output$cluster_insight <- shiny::renderUI({
+ d <- data()
+ if (is.null(d$second))
+ return(shiny::p("Load a second matrix to compare cluster assignments."))
+ c <- concordance()
+ shiny::div(class = "start-status ready",
+ shiny::strong(sprintf("%d shared isolates were partitioned into %d groups in each matrix.",
+ nrow(c$assignments), input$cluster_k)),
+ shiny::p(sprintf(
+ "Adjusted Rand index: %s. Adjusted Wallace, %s to %s: %s; in reverse: %s.",
+ manir_fmt(c$ari), d$label1, d$label2,
+ manir_fmt(c$adjusted_wallace_1_to_2),
+ manir_fmt(c$adjusted_wallace_2_to_1))),
+ shiny::p("The group numbers are arbitrary. Use the overlap table to identify groups that split between methods, then inspect those isolates in the heatmap or pair view.")
+ )
+ })
output$cluster_intro <- shiny::renderUI({
shiny::req(has_two())
d <- data()
@@ -771,9 +821,10 @@ server <- function(input, output, session) {
})
output$cluster_summary <- shiny::renderTable({
c <- concordance()
+ d <- data()
data.frame(measure = c("Adjusted Rand index",
- "Adjusted Wallace (matrix 1 -> matrix 2)",
- "Adjusted Wallace (matrix 2 -> matrix 1)"),
+ sprintf("Adjusted Wallace (%s -> %s)", d$label1, d$label2),
+ sprintf("Adjusted Wallace (%s -> %s)", d$label2, d$label1)),
value = c(c$ari, c$adjusted_wallace_1_to_2,
c$adjusted_wallace_2_to_1))
})
@@ -787,7 +838,9 @@ server <- function(input, output, session) {
}, rownames = FALSE)
output$cluster_table <- shiny::renderTable({
assignment <- utils::head(concordance()$assignments, 100L)
- names(assignment) <- c("Isolate", "Matrix 1 cluster", "Matrix 2 cluster")
+ d <- data()
+ names(assignment) <- c("Isolate", paste(d$label1, "cluster"),
+ paste(d$label2, "cluster"))
assignment
}, rownames = FALSE)
group_summary <- shiny::reactive({
From 5dc0b686365c8fc191d87c9c20ed2395721a7956 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:16:04 +0200
Subject: [PATCH 123/134] docs: provide a question-led MAniR research
walkthrough with real next steps
---
docs/RESEARCH_WORKFLOWS.md | 127 +++++++++++++++++++++++++++++++++++++
1 file changed, 127 insertions(+)
create mode 100644 docs/RESEARCH_WORKFLOWS.md
diff --git a/docs/RESEARCH_WORKFLOWS.md b/docs/RESEARCH_WORKFLOWS.md
new file mode 100644
index 0000000..74673c8
--- /dev/null
+++ b/docs/RESEARCH_WORKFLOWS.md
@@ -0,0 +1,127 @@
+# Analyze a research question with MAniR
+
+MAniR accepts **precomputed, symmetric, named pairwise matrices**. It does not
+process raw MALDI-TOF spectra or calculate ANI from genome assemblies. A matrix
+has one row and one column per isolate. The number at an intersection
+describes that pair. All comparisons require the **same isolate IDs** or a
+deliberately selected intersection.
+
+The application now starts with a question rather than a list of statistical
+tools. You can work through a whole analysis without needing to know which
+statistic to choose in advance.
+
+## Begin with your own data
+
+1. Upload your first matrix. CSV and RDS are preferable for very large
+ datasets; Excel workbooks can contain two matrices and a metadata sheet.
+2. Provide short names such as "ANI from WGS" and "MALDI similarity" and
+ identify each matrix as similarity, correlation or distance. These choices
+ matter for clustering and the direction of rank comparisons.
+3. Upload a second matrix if comparing methods, and a metadata table if
+ examining known biological groups, specimen sources or experimental batches.
+ Choose whether the datasets must contain exactly the same isolates or
+ whether only shared isolates should be compared.
+4. Click **Load and analyze uploaded files**. The start page shows how many
+ samples are available, which questions you can answer and whether matching
+ removed any isolates. You can always return to this page via **All questions**
+ or the **Start here** tab.
+
+To try the workflow without data, click **Load example**. This loads 12 fictional
+isolates, a percentage-like ANI matrix, a 0–1 MALDI-like similarity matrix and
+group/source/batch metadata. These numbers are synthetic and must not be used
+as measured biological evidence.
+
+## Question 1: Do the methods describe similar relationships?
+
+Click **Compare the methods**. Start with Spearman's rank correlation:
+it summarizes whether isolate pairs that rank high in one method tend to
+rank high in the other. Pearson's r describes a linear relationship.
+For distance matrices, MAniR reverses their direction in these association
+summaries so that higher oriented values always mean greater similarity.
+
+Use **Inspect the pairs behind these numbers** to see the original scatterplot.
+An overall correlation can coexist with individual pairs that rank very
+differently, so review them before writing a conclusion. The optional Mantel
+test uses whole-isolate label permutations for complete datasets of at most
+400 isolates. It tests matrix association, not equivalent typing accuracy.
+
+## Question 2: Which isolate pairs deserve follow-up?
+
+Click **Find unusual pairs**. MAniR lists pairs with the largest **rank gaps**:
+pairs occupying noticeably different positions in the two ranked lists.
+Choose a pair from that list, or type any two shared isolate IDs in the
+search boxes. MAniR shows their exact original values and, when selected,
+their metadata group labels. The corresponding point is outlined in the
+scatterplot, even if the pair was absent from a sampled overview.
+
+In the fictional example, try **ISO_02 and ISO_03**. They have ANI-like
+similarity 99.655 and MALDI-like similarity 0.52. A difference in rankings
+is a prompt to inspect the spectra, genome quality and metadata, **not**
+evidence of an error or a statistically significant outlier.
+
+For very large datasets, the scatterplot and ranked list may use up to
+the chosen maximum number of sampled pairs. The direct isolate-pair
+inspector always looks up the original source matrices, not a sampled
+or normalized preview.
+
+## Question 3: Do the methods group isolates similarly?
+
+Click **Compare clusters**. Pick the number of clusters (k) in the toolbar.
+Each matrix is clustered independently using your selected linkage and then
+cut into k groups. The overlap table shows exactly how memberships
+correspond between methods.
+
+The adjusted Rand index compares the two partitions independent of
+their arbitrary numeric cluster labels. The adjusted Wallace coefficients
+ask directional questions and need not be equal. Compare their values with
+the cluster-overlap table before deciding which isolates need inspection.
+Neither measure establishes biological typing accuracy.
+
+Clustering is limited to 2,000 shared isolates in the current implementation.
+For larger data, start by exploring a justified subset or import a
+precomputed sample order.
+
+## Question 4: Are known groups, replicates or batches reflected in the data?
+
+Click **Explore my groups**. Select a group, replicate identifier or batch
+column from **Group / annotation** in the results toolbar. MAniR shows
+within-group and between-group pair counts, mean/median values and separate
+distribution plots for each available matrix, each in its **own original units**.
+
+A difference may indicate a feature worth investigating, but the isolate
+pairs in these plots are **not independent replicates**. Biological groups
+and experimental batches can also be confounded. The summaries are
+descriptive and are not a formal test of batch effects or discrimination.
+
+## Question 5: What does a particular cell mean?
+
+Click **Explore heatmaps** and hover over the cell to see the original
+value. The two combined views put one matrix above the diagonal and the
+other below, reordering both by either method. Each triangle uses its
+own display normalization. Equal colors in different matrices do **not**
+mean equal biological similarity. On large data, zoom into a smaller
+region to inspect it without sending the entire matrix to the browser.
+
+The Difference view is available only after explicitly confirming
+equivalent measurements and units. It is deliberately blocked for the
+mixed-unit teaching example. If ANI is expressed in percentages and
+MALDI similarity is on a 0–1 scale, use the scatterplot and rank-gap
+inspection instead of subtracting the matrices.
+
+## Record your observations
+
+Click **Open exports** or visit **Export** when you have explored the
+question. Enter your research question and write your observations and
+planned follow-up. **Download research notes (.md)** creates an editable
+Markdown record containing the dataset names, analysis settings, pair
+association statistics, and any selected metadata group summaries.
+Cluster statistics are included only if you explicitly enable their
+calculation in Export. This does not automatically run the Mantel test.
+The text includes cautions about pair dependence and synthetic data.
+
+Save the original CSV/RDS matrices alongside the notes and figures.
+Static and HTML plots may show normalized or downsampled previews,
+while CSV and RDS exports retain original values.
+
+For details of each statistical quantity, see
+[Reading MAniR results](INTERPRETING_RESULTS.md).
From 76adb285351d441644bec7d928e1b72d22cf91e3 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:16:20 +0200
Subject: [PATCH 124/134] docs: explain researcher-first workflow and editable
notes
---
README.md | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 715884e..2e32d2d 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,13 @@ MAniR is an R Shiny application for investigating pairwise relationships between
This development branch is **MAniR 3.0 prerelease**. The historical application remains available in the repository's Git history and on `main` until the upgrade is reviewed. Do not cite an unverified performance improvement: the benchmark scripts need to be run on specified hardware.
+**Start with your research question.** After loading data, the first screen
+offers five practical routes: compare methods, inspect unusual isolate pairs,
+compare clusters, explore known groups/batches, or inspect a matrix. You do
+not need to pick a statistic before you know what you are looking for. See the
+[step-by-step research walkthrough](docs/RESEARCH_WORKFLOWS.md) for an
+example and suggested next steps.
+
For help understanding the plots, correlations, directional cluster agreement,
rank gaps, metadata tracks and the restrictions on difference maps, see
[Reading MAniR results](docs/INTERPRETING_RESULTS.md). The application also
@@ -54,7 +61,7 @@ On a desktop window, MAniR keeps the analysis results and control sidebar in
files** button stays visible when you scroll through the upload options.
The right-hand toolbar remains visible when you scroll through a result.
-After you load data, MAniR opens **Heatmaps → Matrix 1** automatically.
+After you load data, MAniR opens **Start here**, where you choose a research question. Choosing **Explore heatmaps** opens **Heatmaps → Matrix 1**.
The primary navigation groups all five heatmap views under **Heatmaps**, with
a compact secondary menu for Matrix 1, Matrix 2, both combined orderings
and Difference. Other tabs are Overview, Pairwise comparison, Cluster
@@ -113,7 +120,7 @@ MAniR accepts **precomputed** MALDI-TOF, ANI, dDDH and other pairwise results. R
## Export and reproducibility
-The export tab provides exact input matrices as CSV, an RDS bundle containing both matrices, optional metadata and sample order, static PNG, PDF and SVG files, a standalone interactive HTML preview and a text manifest of analysis settings and R session information. Pairwise comparison and cluster assignments have their own CSV exports. PDF and SVG contain individual vector cells for matrices with at most 150 isolates and a representative raster for larger inputs. Interactive HTML previews include at most 300 isolates and require Pandoc for self-contained HTML. Large-image exports use representative raster views with up to 1,200 rows and columns, not an exact full-resolution image of a 10,000-isolate matrix. Exact difference matrices can be exported as CSV when both datasets have comparable numerical units.
+The Export tab also provides an editable Markdown research summary with optional researcher notes, group summaries and explicitly requested cluster statistics. It does not run expensive permutation tests automatically. The export tab provides exact input matrices as CSV, an RDS bundle containing both matrices, optional metadata and sample order, static PNG, PDF and SVG files, a standalone interactive HTML preview and a text manifest of analysis settings and R session information. Pairwise comparison and cluster assignments have their own CSV exports. PDF and SVG contain individual vector cells for matrices with at most 150 isolates and a representative raster for larger inputs. Interactive HTML previews include at most 300 isolates and require Pandoc for self-contained HTML. Large-image exports use representative raster views with up to 1,200 rows and columns, not an exact full-resolution image of a 10,000-isolate matrix. Exact difference matrices can be exported as CSV when both datasets have comparable numerical units.
To use MAniR reproducibly, archive the source matrices, the metadata, the settings manifest, the installed R package versions, the generated plots and the resulting tables. Never infer a biological cutoff from the normalized heatmap colors; inspect the original numeric values and justify cutoffs from the relevant study.
From be5155979a729089adb6a469ac336900b0e09983 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:16:23 +0200
Subject: [PATCH 125/134] docs: record question-based analysis changes and
validation scope
---
docs/UPGRADE_WORKLOG.md | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/docs/UPGRADE_WORKLOG.md b/docs/UPGRADE_WORKLOG.md
index f6f3085..fd6c51a 100644
--- a/docs/UPGRADE_WORKLOG.md
+++ b/docs/UPGRADE_WORKLOG.md
@@ -90,3 +90,26 @@ these checks pass. The arXiv manuscript is outside this work scope.
context-sensitive controls: color and zoom only for heatmaps, pair limits
only for pairwise analysis, and k only for cluster comparison. Successful
loading now opens Matrix 1 automatically, without page scrolling.
+
+## September 29, 2026: question-led research workflow
+
+- Added **Start here**, a task-based researcher home screen with five real
+ analysis questions and clear data prerequisites; loading an example or
+ user matrix returns to this screen.
+- Added exact original-value drilldown for any pair of shared isolates,
+ selection from the top ranked disagreements, and point highlighting on
+ the scatterplot. Rank-gap lists may use a reproducibly sampled set of
+ pairs, but direct lookup always reads original matrices.
+- Added metadata within-/between-group tables and separate original-scale
+ distribution plots for each matrix, with explicit caveats about
+ non-independent pairs and batch confounding.
+- Added editable research notes in Markdown with source and analysis details,
+ group summaries and optional explicit clustering. The report does not
+ trigger a Mantel test and does not claim typing accuracy.
+- Added researcher-entered matrix names, compact data-ready messages and
+ question-based navigation; preserved existing detailed tabs for advanced
+ users. Scientific summaries reverse distance direction when interpreting
+ pair relationships, while exact uploaded numbers are unchanged.
+- Added regression and Shiny navigation tests and the walkthrough at
+ docs/RESEARCH_WORKFLOWS.md. Benchmarks still require repeatable hardware
+ measurements before any performance claims.
From cd76d2e98d30ace74577681d8faac2d6e547f341 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:16:55 +0200
Subject: [PATCH 126/134] fix: clearly uppercase synthetic warning in exported
research notes
---
R/research_workflows.R | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/R/research_workflows.R b/R/research_workflows.R
index 50c2d03..cb6fad4 100644
--- a/R/research_workflows.R
+++ b/R/research_workflows.R
@@ -88,7 +88,7 @@ manir_research_report <- function(dataset, pairs = NULL, group_table = NULL,
lines <- c("# MAniR analysis notes", "",
if (nzchar(trimws(question))) paste("**Research question:**",
trimws(question)) else NULL,
- if (example) "**Teaching example: synthetic data, not biological evidence.**" else NULL,
+ if (example) "**SYNTHETIC teaching example: not measured biological evidence.**" else NULL,
"## Dataset", "",
paste("- Source:", dataset$source),
sprintf("- %s: %d isolates (%s)", one, nrow(dataset$first),
From 9cf1fae8747d41e7b2cd68aa9d9ae7e4b507075a Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:33:39 +0200
Subject: [PATCH 127/134] docs: capture real Shiny screenshots with synthetic
example using headless Chromium
---
scripts/capture-docs-screenshots.js | 158 ++++++++++++++++++++++++++++
1 file changed, 158 insertions(+)
create mode 100644 scripts/capture-docs-screenshots.js
diff --git a/scripts/capture-docs-screenshots.js b/scripts/capture-docs-screenshots.js
new file mode 100644
index 0000000..352bac3
--- /dev/null
+++ b/scripts/capture-docs-screenshots.js
@@ -0,0 +1,158 @@
+/* Capture REAL MAniR screens from a running Shiny session.
+ * Use only the bundled SYNTHETIC example, never a researcher's uploaded data.
+ *
+ * Run:
+ * Rscript -e "shiny::runApp('.', host='127.0.0.1', port=3838)" &
+ * npm install --no-save --no-package-lock playwright
+ * node scripts/capture-docs-screenshots.js
+ *
+ * Screenshots are deliberately not retouched or composited.
+ */
+const fs = require("node:fs");
+const path = require("node:path");
+const { chromium } = require("playwright");
+
+const url = process.env.MANIR_SCREENSHOT_URL || "http://127.0.0.1:3838";
+const outputDir = path.resolve("docs/images");
+const screenshotOptions = { animations: "disabled", timeout: 30000 };
+const maxWait = 60000;
+
+function fail(message) {
+ throw new Error("MAniR screenshot capture: " + message);
+}
+
+async function waitForText(page, selector, text) {
+ await page.waitForFunction(
+ ({ selector, text }) =>
+ document.querySelector(selector)?.textContent?.includes(text),
+ { selector, text },
+ { timeout: maxWait }
+ );
+}
+
+async function save(page, name) {
+ await page.screenshot({
+ path: path.join(outputDir, name),
+ fullPage: false,
+ ...screenshotOptions
+ });
+ const size = fs.statSync(path.join(outputDir, name)).size;
+ if (size < 8000) fail(name + " looks unexpectedly empty (" + size + " bytes)");
+ console.log("Captured " + name + " (" + Math.round(size / 1024) + " KiB)");
+}
+
+async function goStart(page) {
+ await page.locator('a[data-toggle="tab"]')
+ .filter({ hasText: /^Start here$/ }).first().click();
+ await page.locator("#start_heatmap").waitFor({ timeout: maxWait });
+}
+
+async function capture() {
+ fs.mkdirSync(outputDir, { recursive: true });
+ const args = ["--no-sandbox", "--disable-dev-shm-usage"];
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROME_BIN || undefined,
+ args
+ });
+ const page = await browser.newPage({
+ viewport: { width: 1680, height: 1000 },
+ deviceScaleFactor: 1,
+ colorScheme: "light",
+ reducedMotion: "reduce"
+ });
+ const errors = [];
+ page.on("pageerror", e => errors.push(e.message));
+ page.on("console", message => {
+ if (message.type() === "error") console.log("Browser:", message.text());
+ });
+
+ try {
+ const response = await page.goto(url, {
+ waitUntil: "domcontentloaded",
+ timeout: maxWait
+ });
+ if (!response?.ok()) fail("Shiny app returned HTTP " + response?.status());
+ await page.locator("#load_example").waitFor({ timeout: maxWait });
+ await page.locator("#load_example").click();
+ await waitForText(page, "#research_status", "Teaching example ready");
+ await waitForText(page, "#research_status", "12");
+ // Give the example-loaded notification time to disappear.
+ await page.waitForTimeout(4400);
+ await save(page, "start-here.png");
+
+ // Real Plotly heatmap, with the default metadata field in the example.
+ await page.locator("#start_heatmap").click();
+ await page.locator("#plot1_interactive .main-svg")
+ .waitFor({ timeout: maxWait });
+ await waitForText(page, "#plot1_intro", "12 isolates");
+ await page.waitForTimeout(600);
+ await save(page, "heatmap.png");
+
+ // Real split-triangle view, both matrices ordered by the first.
+ await page.locator('a[data-toggle="tab"]')
+ .filter({ hasText: /^Combined \(order 1\)$/ }).first().click();
+ await page.locator("#combined1_interactive .main-svg")
+ .waitFor({ timeout: maxWait });
+ await page.waitForTimeout(600);
+ await save(page, "combined-heatmap.png");
+
+ await goStart(page);
+ await page.locator("#start_agreement").click();
+ await page.locator("#agreement table").waitFor({ timeout: maxWait });
+ await page.waitForTimeout(300);
+ await save(page, "method-overview.png");
+
+ await goStart(page);
+ await page.locator("#start_pairs").click();
+ await waitForText(page, "#pair_inspection", "0.52");
+ await page.locator("#scatter img").waitFor({ timeout: maxWait });
+ await page.waitForTimeout(400);
+ await save(page, "pairwise-comparison.png");
+ await page.locator(".pair-focus").screenshot({
+ path: path.join(outputDir, "pair-inspector.png"),
+ ...screenshotOptions
+ });
+
+ await goStart(page);
+ await page.locator("#start_cluster").click();
+ await page.locator("#cluster_summary table").waitFor({ timeout: maxWait });
+ await page.locator("#cluster_overlap table").waitFor({ timeout: maxWait });
+ await page.waitForTimeout(400);
+ await save(page, "cluster-comparison.png");
+
+ await goStart(page);
+ await page.locator("#start_groups").click();
+ await waitForText(page, "#metadata_group_status", "Exploring");
+ await page.locator("#group_distributions img")
+ .waitFor({ timeout: maxWait });
+ await page.waitForTimeout(400);
+ await save(page, "metadata-groups.png");
+
+ await goStart(page);
+ await page.locator("#start_export").click();
+ await page.locator("#research_question").fill(
+ "Do MALDI-TOF similarities reflect genomic ANI relationships?"
+ );
+ await page.locator("#research_notes").fill(
+ "Synthetic teaching example. Inspect discordant isolate pairs and " +
+ "check whether the selected metadata groups explain the difference. " +
+ "Follow up with real spectra and sequence quality controls."
+ );
+ await page.waitForTimeout(300);
+ await save(page, "research-notes-export.png");
+
+ if (errors.length) {
+ // Shiny/Plotly can issue nonfatal widget warnings. Retain logs for
+ // review instead of claiming the screenshots are error-free.
+ console.warn("Browser JS messages:", errors.join(" | "));
+ }
+ } finally {
+ await browser.close();
+ }
+}
+
+capture().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
From 5c12535cb475be36c6c69d328b409e9b36d27e00 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:34:05 +0200
Subject: [PATCH 128/134] ci(docs): run real Shiny and capture reproducible
example screenshots in GitHub Actions
---
.github/workflows/docs-screenshots.yml | 99 ++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
create mode 100644 .github/workflows/docs-screenshots.yml
diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml
new file mode 100644
index 0000000..20b55e8
--- /dev/null
+++ b/.github/workflows/docs-screenshots.yml
@@ -0,0 +1,99 @@
+name: Capture real MAniR documentation screenshots
+
+on:
+ push:
+ branches:
+ - upgrade/scalable-analysis
+ paths:
+ - scripts/capture-docs-screenshots.js
+ - .github/workflows/docs-screenshots.yml
+ - app.R
+ - ui.R
+ - server.R
+ - R/**
+ - www/**
+ - examples/**
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ capture-screens:
+ name: Real Shiny screenshot capture
+ if: github.ref == 'refs/heads/upgrade/scalable-analysis'
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: true
+
+ - uses: r-lib/actions/setup-r@v2
+ with:
+ use-public-rspm: true
+
+ - uses: r-lib/actions/setup-r-dependencies@v2
+ with:
+ cache-version: 1
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install browser driver
+ run: |
+ npm install --no-save --no-package-lock playwright@1.55.1
+ if command -v google-chrome >/dev/null 2>&1; then
+ echo "CHROME_BIN=$(command -v google-chrome)" >> "$GITHUB_ENV"
+ elif command -v chromium >/dev/null 2>&1; then
+ echo "CHROME_BIN=$(command -v chromium)" >> "$GITHUB_ENV"
+ else
+ npx playwright install --with-deps chromium
+ fi
+
+ - name: Start real MAniR Shiny app
+ run: |
+ nohup Rscript -e "shiny::runApp('.', host='127.0.0.1', port=3838, launch.browser=FALSE)" \
+ > "$RUNNER_TEMP/manir-shiny.log" 2>&1 &
+ echo $! > "$RUNNER_TEMP/manir-shiny.pid"
+ for attempt in $(seq 1 90); do
+ if curl --silent --fail --output /dev/null http://127.0.0.1:3838; then
+ echo "MAniR is running"; break
+ fi
+ sleep 2
+ done
+ curl --silent --fail http://127.0.0.1:3838 >/dev/null || {
+ cat "$RUNNER_TEMP/manir-shiny.log"
+ exit 1
+ }
+
+ - name: Capture actual example-data screens
+ run: node scripts/capture-docs-screenshots.js
+ env:
+ MANIR_SCREENSHOT_URL: http://127.0.0.1:3838
+
+ - name: Upload screenshots for review
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: actual-manir-screenshots
+ path: docs/images/*.png
+ if-no-files-found: warn
+
+ - name: Store actual screenshots on development branch
+ if: success()
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add docs/images/*.png
+ if git diff --cached --quiet; then
+ echo "Documentation screenshots unchanged"
+ else
+ git commit -m "docs: update real Shiny screenshots from synthetic example"
+ git push origin HEAD:upgrade/scalable-analysis
+ fi
+
+ - name: Show Shiny server logs on failure
+ if: failure()
+ run: cat "$RUNNER_TEMP/manir-shiny.log" || true
From 682973bcc74cc7896bb0ed1de3ae86e4b5575eb4 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:35:41 +0200
Subject: [PATCH 129/134] docs: add real application screenshot gallery to
README
---
README.md | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/README.md b/README.md
index 2e32d2d..cb15468 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,28 @@ rank gaps, metadata tracks and the restrictions on difference maps, see
[Reading MAniR results](docs/INTERPRETING_RESULTS.md). The application also
displays a short explanation directly on each analysis tab.
+## See the actual interface
+
+These screenshots are captured from a running copy of MAniR with the bundled
+**synthetic example**. They show the real Shiny controls and output, rather
+than design illustrations. The [research walkthrough](docs/RESEARCH_WORKFLOWS.md)
+has a screenshot for each step.
+
+
+
+*The Start here page: load data, then choose the question you want to answer.*
+
+| Explore sample relationships | Investigate discordant isolate pairs |
+| --- | --- |
+|  |  |
+
+| Compare cluster assignments | Explore groups and batches |
+| --- | --- |
+|  |  |
+
+*Screens are recorded at 1680 × 1000 using the synthetic example. Layout and
+colors may differ at other window sizes or after you adjust settings.*
+
## Installation
Install R 4.2 or later. The upload limit defaults to 1,024 MB and can be configured with the `MANIR_MAX_UPLOAD_MB` environment variable. Plan available RAM for at least the uploaded matrices plus working data, especially for two 10,000-isolate matrices. For those larger projects, RDS or CSV is preferable to Excel. In the repository directory, run:
From 4b2bf85c43dc0cd013af0e29da536553afcc4fa7 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:35:44 +0200
Subject: [PATCH 130/134] docs: illustrate researcher workflows with actual
Shiny captures
---
docs/RESEARCH_WORKFLOWS.md | 51 ++++++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/docs/RESEARCH_WORKFLOWS.md b/docs/RESEARCH_WORKFLOWS.md
index 74673c8..b29f633 100644
--- a/docs/RESEARCH_WORKFLOWS.md
+++ b/docs/RESEARCH_WORKFLOWS.md
@@ -10,6 +10,11 @@ The application now starts with a question rather than a list of statistical
tools. You can work through a whole analysis without needing to know which
statistic to choose in advance.
+
+
+*Actual Shiny app after loading the bundled synthetic example. Choose a
+research question to jump directly to the relevant results.*
+
## Begin with your own data
1. Upload your first matrix. CSV and RDS are preferable for very large
@@ -33,6 +38,12 @@ as measured biological evidence.
## Question 1: Do the methods describe similar relationships?
+
+
+*Overview: the original matrices' association summary, with a direct link to
+inspect the isolate pairs behind it.*
+
+
Click **Compare the methods**. Start with Spearman's rank correlation:
it summarizes whether isolate pairs that rank high in one method tend to
rank high in the other. Pearson's r describes a linear relationship.
@@ -47,6 +58,17 @@ test uses whole-isolate label permutations for complete datasets of at most
## Question 2: Which isolate pairs deserve follow-up?
+
+
+*Each scatterplot point represents a unique isolate pair. The pair inspector
+and rank-gap list appear in this view.*
+
+
+
+*Select two isolates to read the original values and available metadata.
+The highlighted example pair uses fictional data.*
+
+
Click **Find unusual pairs**. MAniR lists pairs with the largest **rank gaps**:
pairs occupying noticeably different positions in the two ranked lists.
Choose a pair from that list, or type any two shared isolate IDs in the
@@ -66,6 +88,12 @@ or normalized preview.
## Question 3: Do the methods group isolates similarly?
+
+
+*Cluster comparison: agreement measures, overlap and memberships for shared
+isolates.*
+
+
Click **Compare clusters**. Pick the number of clusters (k) in the toolbar.
Each matrix is clustered independently using your selected linkage and then
cut into k groups. The overlap table shows exactly how memberships
@@ -83,6 +111,12 @@ precomputed sample order.
## Question 4: Are known groups, replicates or batches reflected in the data?
+
+
+*Choose a metadata field to see descriptive within-group and between-group
+measurements on each matrix's original scale.*
+
+
Click **Explore my groups**. Select a group, replicate identifier or batch
column from **Group / annotation** in the results toolbar. MAniR shows
within-group and between-group pair counts, mean/median values and separate
@@ -95,6 +129,17 @@ descriptive and are not a formal test of batch effects or discrimination.
## Question 5: What does a particular cell mean?
+
+
+*The first matrix view. Hover over a cell in the live app for its exact
+original numerical value.*
+
+
+
+*The combined view displays different input matrices in the two triangles;
+each triangle uses independent display normalization.*
+
+
Click **Explore heatmaps** and hover over the cell to see the original
value. The two combined views put one matrix above the diagonal and the
other below, reordering both by either method. Each triangle uses its
@@ -110,6 +155,12 @@ inspection instead of subtracting the matrices.
## Record your observations
+
+
+*The export page with illustrative notes entered during screenshot capture.
+The app does not automatically write these observations or conclusions.*
+
+
Click **Open exports** or visit **Export** when you have explored the
question. Enter your research question and write your observations and
planned follow-up. **Download research notes (.md)** creates an editable
From f039f5b38c2f50164752d2eb76522ed0854273cf Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:35:47 +0200
Subject: [PATCH 131/134] docs: pair interpretation guidance with actual app
screenshots
---
docs/INTERPRETING_RESULTS.md | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/docs/INTERPRETING_RESULTS.md b/docs/INTERPRETING_RESULTS.md
index 739c7ac..f995a3d 100644
--- a/docs/INTERPRETING_RESULTS.md
+++ b/docs/INTERPRETING_RESULTS.md
@@ -25,6 +25,11 @@ transformation changes the input type.
## Reading single heatmaps
+
+
+*The actual Matrix 1 tab with the bundled synthetic data.*
+
+
Each cell is one row-isolate versus column-isolate comparison. A symmetric
matrix repeats the same number on both sides of the diagonal. Clustering
reorders the rows and columns **together**, placing similar patterns adjacent.
@@ -47,6 +52,11 @@ over the plot.
## The two combined views
+
+
+*The actual split-matrix view. Its two triangles are scaled separately.*
+
+
A split matrix uses **matrix 1 above the diagonal** and **matrix 2 below
the diagonal**, with the same isolates along both axes. Combined (order 1)
uses clustering from matrix 1; Combined (order 2) puts matrix 2 above the
@@ -61,6 +71,15 @@ contributed a cell and its original value.
## Pairwise comparison and rank gaps
+
+
+*The actual scatterplot and pair selection workflow.*
+
+
+
+*Inspect the original numerical values for a selected isolate pair.*
+
+
Each scatterplot dot shows one unique unordered pair of isolates.
For n isolates, there are n(n-1)/2 pairs, excluding the diagonal and the
mirrored half. With the 12-isolate example, that is 66 pairs. Matrix 1
@@ -99,6 +118,12 @@ describe **that sample**, not necessarily every pair in the input.
## Cluster agreement
+
+
+*Compare the group assignments across methods instead of comparing arbitrary
+cluster numbers directly.*
+
+
Choose the number of clusters k in the sidebar. MAniR independently
clusters each matrix using its specified type, then cuts both trees into
k groups. The linkage method and choice of k may change the result.
@@ -150,6 +175,16 @@ A difference by itself does not establish which method is correct.
## Metadata and reproducibility
+
+
+*Within-group and between-group summaries for the selected metadata field.*
+
+
+
+*Save an editable research record alongside the original matrices and
+figures.*
+
+
Metadata categories (group, source, batch, etc.) are descriptive.
Within-group versus between-group pair summaries may suggest something
to investigate, but they are not independent-replicate statistical tests
From ee22e3fe75513e2b2e9410d58297f396a38a9b06 Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:36:13 +0200
Subject: [PATCH 132/134] fix(docs): target first of Plotly's multiple SVG
layers in real screenshot capture
---
scripts/capture-docs-screenshots.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/scripts/capture-docs-screenshots.js b/scripts/capture-docs-screenshots.js
index 352bac3..afb260f 100644
--- a/scripts/capture-docs-screenshots.js
+++ b/scripts/capture-docs-screenshots.js
@@ -83,7 +83,7 @@ async function capture() {
// Real Plotly heatmap, with the default metadata field in the example.
await page.locator("#start_heatmap").click();
- await page.locator("#plot1_interactive .main-svg")
+ await page.locator("#plot1_interactive .main-svg").first()
.waitFor({ timeout: maxWait });
await waitForText(page, "#plot1_intro", "12 isolates");
await page.waitForTimeout(600);
@@ -92,7 +92,7 @@ async function capture() {
// Real split-triangle view, both matrices ordered by the first.
await page.locator('a[data-toggle="tab"]')
.filter({ hasText: /^Combined \(order 1\)$/ }).first().click();
- await page.locator("#combined1_interactive .main-svg")
+ await page.locator("#combined1_interactive .main-svg").first()
.waitFor({ timeout: maxWait });
await page.waitForTimeout(600);
await save(page, "combined-heatmap.png");
From 0fdd220db1871b1e8351ce13af076d20126ed95c Mon Sep 17 00:00:00 2001
From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:37:59 +0200
Subject: [PATCH 133/134] ci(docs): serialize real screenshot captures and
rebase image commits onto updated docs
---
.github/workflows/docs-screenshots.yml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml
index 20b55e8..c15c929 100644
--- a/.github/workflows/docs-screenshots.yml
+++ b/.github/workflows/docs-screenshots.yml
@@ -15,6 +15,10 @@ on:
- examples/**
workflow_dispatch:
+concurrency:
+ group: manir-real-screenshots-${{ github.ref }}
+ cancel-in-progress: true
+
permissions:
contents: write
@@ -91,6 +95,9 @@ jobs:
echo "Documentation screenshots unchanged"
else
git commit -m "docs: update real Shiny screenshots from synthetic example"
+ # The docs may have changed while this browser job was running.
+ git fetch origin upgrade/scalable-analysis
+ git rebase origin/upgrade/scalable-analysis
git push origin HEAD:upgrade/scalable-analysis
fi
From 229ad90cb421a1d5e03b437ed8181a1a46943c3f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 29 Sep 2026 12:40:00 +0000
Subject: [PATCH 134/134] docs: update real Shiny screenshots from synthetic
example
---
docs/images/cluster-comparison.png | Bin 0 -> 175908 bytes
docs/images/combined-heatmap.png | Bin 0 -> 159262 bytes
docs/images/heatmap.png | Bin 0 -> 152668 bytes
docs/images/metadata-groups.png | Bin 0 -> 194094 bytes
docs/images/method-overview.png | Bin 0 -> 192129 bytes
docs/images/pair-inspector.png | Bin 0 -> 37114 bytes
docs/images/pairwise-comparison.png | Bin 0 -> 154581 bytes
docs/images/research-notes-export.png | Bin 0 -> 159420 bytes
docs/images/start-here.png | Bin 0 -> 186292 bytes
9 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 docs/images/cluster-comparison.png
create mode 100644 docs/images/combined-heatmap.png
create mode 100644 docs/images/heatmap.png
create mode 100644 docs/images/metadata-groups.png
create mode 100644 docs/images/method-overview.png
create mode 100644 docs/images/pair-inspector.png
create mode 100644 docs/images/pairwise-comparison.png
create mode 100644 docs/images/research-notes-export.png
create mode 100644 docs/images/start-here.png
diff --git a/docs/images/cluster-comparison.png b/docs/images/cluster-comparison.png
new file mode 100644
index 0000000000000000000000000000000000000000..fa91c92ddc7862ff6acaf445fe1fcc75dc28851c
GIT binary patch
literal 175908
zcmdqJWmH_v6Fx{n5+DQ!?ht|ncO4`I3m)7(K+wU40fvDPf&>lj5Zqm7&>$g$yL<4#
zoxzsZe&_#j&wkmndp}LxGq<~MS9NvOQ%~P-A5`UWo>M$WLqo$+crT-YhW6|`8XEc*
z=HrJuI+(2KXlO6d6lA2dJkxd;FpM;(Rxu7WRo)R0VA6$GV*h^m@(Hs>U}%YAw{cNf
zy@8>jA++qS#Di-OGBIUWHaVhW7zVO54tX>D825?P+n~4P)zX*@40JHmoshXw8tBI@
zRI+u-c~fL_@yR~d=6v5LtzxW)j{mt%U$kKU_Z#h$40HE?g7?cO9{&@>F&_i|6F$>C
z@u&Vzc#HR?9rr)s2Zodx#(%
Cwam9E$9~w8GE+#C
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