From 4cc910d4850471605a3ad9d7807626ae774c29df Mon Sep 17 00:00:00 2001 From: Rudhik1904 Date: Mon, 31 Aug 2026 00:31:34 -0400 Subject: [PATCH 1/2] Task 3 --- .Rbuildignore | 1 + .gitignore | 3 + R/dataProcessPlots.R | 131 ++++++--- R/utils_dataprocess_plots.R | 274 +++++++++++++++++-- inst/NEWS.rd | 9 + inst/tinytest/test_utils_dataprocess_plots.R | 176 ++++++++++++ man/dataProcessPlots.Rd | 36 ++- man/dot-conditionLayoutSpecs.Rd | 37 +++ man/dot-layoutConditionLabels.Rd | 60 ++++ man/dot-makeConditionPlot.Rd | 6 +- man/dot-makeProfilePlot.Rd | 39 ++- man/dot-makeQCPlot.Rd | 19 +- man/dot-makeSummaryProfilePlot.Rd | 24 +- man/dot-wrapConditionLabels.Rd | 23 ++ 14 files changed, 758 insertions(+), 80 deletions(-) create mode 100644 inst/tinytest/test_utils_dataprocess_plots.R create mode 100644 man/dot-conditionLayoutSpecs.Rd create mode 100644 man/dot-layoutConditionLabels.Rd create mode 100644 man/dot-wrapConditionLabels.Rd diff --git a/.Rbuildignore b/.Rbuildignore index 51faea84..53342401 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -7,3 +7,4 @@ appspec.yml ^\.positai$ ^\.claude$ +^plan$ diff --git a/.gitignore b/.gitignore index 230ee3ad..480f9e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ *.Rproj .positai + +# Local planning docs (not part of the package) +plan/ diff --git a/R/dataProcessPlots.R b/R/dataProcessPlots.R index 76da2a7e..e9a9c22f 100644 --- a/R/dataProcessPlots.R +++ b/R/dataProcessPlots.R @@ -40,7 +40,11 @@ #' graph in Profile Plot and QC plot. Default is 4. #' @param text.angle angle of labels represented each condition at the top #' of graph in Profile Plot and QC plot or x-axis labeling in Condition plot. -#' Default is 0. +#' Default is 0. This affects the ggplot2 (PDF) output only: text rotation is +#' not carried through to the Plotly output by \code{ggplotly}, so it has no +#' effect when \code{isPlotly = TRUE}. Setting a non-zero value also disables +#' the automatic condition label layout described in +#' \code{condition.label.adjust}. #' @param legend.size size of feature legend (transition-level or peptide-level) #' above graph in Profile Plot. Default is 7. #' @param dot.size.profile size of dots in profile plot. Default is 2. @@ -73,6 +77,26 @@ #' The command address can help to specify where to store the file as well as #' how to modify the beginning of the file name. #' If address=FALSE, plot will be not saved as pdf file but showed in window. +#' @param legend.position position of the feature legend in Profile Plot: "top" +#' (default), "bottom", "left", "right", or "none" to hide it. Honoured in both +#' the ggplot2 (PDF) and the Plotly output. +#' @param legend.ncol number of columns in the feature legend of Profile Plot. +#' Default is NULL, which uses three columns. +#' @param max.legend.entries maximum number of features drawn in the Profile Plot +#' feature legend. Default is 30. A protein with more features than this is drawn +#' without the feature legend, and the feature count is appended to the plot +#' title, for example "P12345 (149 features; legend omitted)", so that the +#' omission is visible rather than silent. Use \code{Inf} to always draw the +#' legend. \code{featureName = "NA"} still suppresses the legend outright. +#' Note that on the Plotly path \code{ggplotly} truncates long legends before +#' this limit is reached, so there the title note rather than the limit is what +#' reports the omission. +#' @param condition.label.adjust TRUE (default) lays out the condition labels of +#' Profile Plot and QC Plot automatically. When a condition name is wider than +#' the horizontal space available to it, the condition is drawn as a facet strip +#' and the name is wrapped to fit, rather than being printed inside the panel +#' where it would overlap its neighbours. Plots whose condition labels already +#' fit are left unchanged. FALSE reproduces the previous layout exactly. #' #' @details #' \itemize{ @@ -114,7 +138,13 @@ dataProcessPlots = function( text.size = 4, text.angle = 0, legend.size = 7, dot.size.profile = 2, dot.size.condition = 3, width = 800, height = 600, which.Protein = "all", originalPlot = TRUE, summaryPlot = TRUE, save_condition_plot_result = FALSE, - remove_uninformative_feature_outlier = FALSE, address = "", isPlotly = FALSE + remove_uninformative_feature_outlier = FALSE, address = "", isPlotly = FALSE, + # max.legend.entries = 30 is calibrated, not arbitrary; the evidence and the + # open argument for raising it are recorded at the cap in .makeProfilePlot() + # (R/utils_dataprocess_plots.R). Keep the three defaults here in step with the + # copies on .plotProfile() and .makeProfilePlot(). + legend.position = "top", legend.ncol = NULL, max.legend.entries = 30, + condition.label.adjust = TRUE ) { PROTEIN = Protein = NULL @@ -144,7 +174,9 @@ dataProcessPlots = function( plots <- .plotProfile(processed, summarized, featureName, ylimUp, ylimDown, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, width, height, which.Protein, originalPlot, - summaryPlot, remove_uninformative_feature_outlier, address, isPlotly) + summaryPlot, remove_uninformative_feature_outlier, address, isPlotly, + legend.position, legend.ncol, max.legend.entries, + condition.label.adjust) plotly_plots = list() if(isPlotly) { og_plotly_plot = NULL @@ -152,7 +184,8 @@ dataProcessPlots = function( if("original_plot" %in% names(plots)) { for(i in seq_along(plots[["original_plot"]])) { plot_i <- plots[["original_plot"]][[paste("plot",i)]] - og_plotly_plot <- .convertGgplot2Plotly(plot_i,tips=c("FEATURE","RUN","newABUNDANCE")) + og_plotly_plot <- .convertGgplot2Plotly(plot_i,tips=c("FEATURE","RUN","newABUNDANCE"), + legend_position = legend.position) og_plotly_plot = .fixLegendPlotlyPlotsDataprocess(og_plotly_plot) og_plotly_plot = .fixCensoredPointsLegendProfilePlotsPlotly(og_plotly_plot) og_plotly_plot = .fixErrorBarCapsPlotly(og_plotly_plot) @@ -166,7 +199,8 @@ dataProcessPlots = function( if("summary_plot" %in% names(plots)) { for(i in seq_along(plots[["summary_plot"]])) { plot_i <- plots[["summary_plot"]][[paste("plot",i)]] - summ_plotly_plot <- .convertGgplot2Plotly(plot_i,tips=c("FEATURE","RUN","newABUNDANCE")) + summ_plotly_plot <- .convertGgplot2Plotly(plot_i,tips=c("FEATURE","RUN","newABUNDANCE"), + legend_position = legend.position) summ_plotly_plot = .fixLegendPlotlyPlotsDataprocess(summ_plotly_plot) summ_plotly_plot = .fixCensoredPointsLegendProfilePlotsPlotly(summ_plotly_plot) summ_plotly_plot = .fixErrorBarCapsPlotly(summ_plotly_plot) @@ -187,7 +221,7 @@ dataProcessPlots = function( else if (type == "QCPLOT") { plots <- .plotQC(processed, featureName, ylimUp, ylimDown, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, width, height, - which.Protein, address, isPlotly) + which.Protein, address, isPlotly, condition.label.adjust) plotly_plots <- vector("list", length(plots)) if(isPlotly) { for(i in seq_along(plots)) { @@ -233,7 +267,9 @@ dataProcessPlots = function( .plotProfile = function( processed, summarized, featureName, ylimUp, ylimDown, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, width, height, proteins, - originalPlot, summaryPlot, remove_uninformative_feature_outlier, address, isPlotly + originalPlot, summaryPlot, remove_uninformative_feature_outlier, address, isPlotly, + legend.position = "top", legend.ncol = NULL, max.legend.entries = 30, + condition.label.adjust = TRUE ) { ABUNDANCE = PROTEIN = feature_quality = is_outlier = Protein = GROUP = NULL SUBJECT = LABEL = RUN = xtabs = PEPTIDE = FEATURE = NULL @@ -349,7 +385,9 @@ dataProcessPlots = function( text.size, text.angle, legend.size, dot.size.profile, ss, s, cumGroupAxis, yaxis.name, - lineNameAxis, groupNametemp, dot_colors) + lineNameAxis, groupNametemp, dot_colors, + legend.position, legend.ncol, max.legend.entries, + width, condition.label.adjust) setTxtProgressBar(pb, i) print(profile_plot) @@ -403,17 +441,23 @@ dataProcessPlots = function( quant$analysis = "Run summary" quant$newABUNDANCE = quant$ABUNDANCE single_protein$analysis = "Processed feature-level data" + # GROUP is carried through so the summary plot can facet on condition when + # .layoutConditionLabels() asks for it; quant is built per RUN and has no + # GROUP of its own, so it is mapped back through the run/condition table. + quant[, GROUP := tempGroupName$GROUP[match(RUN, tempGroupName$RUN)]] combined = rbind(single_protein[ , - list(PROTEIN, PEPTIDE, TRANSITION, FEATURE, LABEL, + list(PROTEIN, PEPTIDE, TRANSITION, FEATURE, LABEL, GROUP, RUN, ABUNDANCE, newABUNDANCE,FRACTION, censored, analysis)], quant,fill=TRUE) + combined$GROUP = factor(combined$GROUP, levels = level.group) combined$analysis = factor(combined$analysis) combined$FEATURE = factor(combined$FEATURE) combined$RUN = as.numeric(combined$RUN) profile_plot = .makeSummaryProfilePlot( combined, is_censored, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, cumGroupAxis, - yaxis.name, lineNameAxis, groupNametemp + yaxis.name, lineNameAxis, groupNametemp, legend.position, + width, condition.label.adjust ) print(profile_plot) setTxtProgressBar(pb, i) @@ -437,7 +481,8 @@ dataProcessPlots = function( #' @importFrom utils setTxtProgressBar .plotQC = function( processed, featureName, ylimUp, ylimDown, x.axis.size, y.axis.size, text.size, - text.angle, legend.size, dot.size.profile, width, height, protein, address, isPlotly + text.angle, legend.size, dot.size.profile, width, height, protein, address, isPlotly, + condition.label.adjust = TRUE ) { GROUP = SUBJECT = RUN = LABEL = PROTEIN = NULL @@ -491,7 +536,7 @@ dataProcessPlots = function( qc_plot = .makeQCPlot(processed, TRUE, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, label.color, cumGroupAxis, groupName, lineNameAxis, - yaxis.name) + yaxis.name, width, condition.label.adjust) print(qc_plot) plots[[1]] = qc_plot } @@ -514,7 +559,7 @@ dataProcessPlots = function( qc_plot = .makeQCPlot(single_protein, FALSE, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, label.color, cumGroupAxis, groupName, - lineNameAxis, yaxis.name) + lineNameAxis, yaxis.name, width, condition.label.adjust) print(qc_plot) plots[[i+1]] = qc_plot # to accomodate all proteins setTxtProgressBar(pb, i) @@ -617,36 +662,38 @@ dataProcessPlots = function( #' converter for plots from ggplot to plotly #' @noRd -.convertGgplot2Plotly = function(plot, tips = "all") { - converted_plot <- ggplotly(plot,tooltip = tips) - converted_plot <- plotly::layout( - converted_plot, - width = 800, # Set the width of the chart in pixels - height = 600, # Set the height of the chart in pixels - title = list( - font = list( - size = 18 - ) - ), - xaxis = list( - titlefont = list( - size = 15 # Set the font size for the x-axis label - ) - ), - legend = list( - x = 0, # Set the x position of the legend - y = -0.25, # Set the y position of the legend (negative value to move below the plot) - orientation = "h", # Horizontal orientation - font = list( - size = 12 # Set the font size for legend item labels - ), - title = list( - font = list( - size = 12 # Set the font size for the legend title - ) - ) +.convertGgplot2Plotly = function(plot, tips = "all", legend_position = NULL) { + # width/height belong to ggplotly(); passing them to layout() is deprecated. + converted_plot <- ggplotly(plot, tooltip = tips, width = 800, height = 600) + args <- list( + converted_plot, + title = list( + font = list( + size = 18 ) - ) + ), + xaxis = list( + titlefont = list( + size = 15 # Set the font size for the x-axis label + ) + ) + ) + if (is.null(legend_position)) { + # Historical placement, kept for callers that do not opt in so their + # output is unchanged. + args$legend <- list( + x = 0, + y = -0.25, + orientation = "h", + font = list(size = 12), + title = list(font = list(size = 12)) + ) + } else { + # ggplotly() already translates theme(legend.position); overriding it + # here would undo that. Only honour an explicit request to hide it. + args$showlegend <- !identical(as.character(legend_position), "none") + } + converted_plot <- do.call(plotly::layout, args) converted_plot } diff --git a/R/utils_dataprocess_plots.R b/R/utils_dataprocess_plots.R index 5036773a..f19a8199 100644 --- a/R/utils_dataprocess_plots.R +++ b/R/utils_dataprocess_plots.R @@ -33,6 +33,164 @@ } +#' Decide how condition labels should be laid out +#' +#' Condition names are drawn inside the panel by `geom_text()`, one per +#' condition, and conditions tile the panel evenly. Each name gets +#' `panel_width / n_conditions` of horizontal room no matter how many runs it +#' contains -- runs per condition do not affect label spacing at all, which is +#' why crowding is measured as name width relative to that slot and never as a +#' sample count. +#' +#' Once a name is wider than its slot, the in-panel layout cannot be rescued by +#' moving text about. Rotation does not survive `ggplotly()` and MSstatsShiny +#' renders through it; `ggrepel` drops labels outright for the same reason; +#' shrinking the font enough to fit 30-character names reaches ~2pt, which is +#' unreadable. Wrapping and staggering inside the panel do make the names +#' legible, but only by printing them on top of the data. +#' +#' So past the threshold the condition becomes a facet instead, and ggplot2 +#' reserves a strip band for it -- collision with the data and clipping at the +#' panel edge stop being possible rather than being mitigated. Verified to +#' survive `ggplotly()`: the strips, `space = "free_x"` sizing, and newline +#' wrapping (which is translated to `
`). +#' +#' @param groupName data.frame of RUN, ABUNDANCE and Name, as built by +#' `.plotProfile()` and `.plotQC()` +#' @param n_facets number of facet panels actually drawn. Pass +#' `length(unique(input$LABEL))`, not `nlevels()`: LABEL is a factor over the +#' whole table, so `nlevels()` reports 2 for a protein carrying only one label +#' while `facet_grid()` draws a single panel, and the labels would then be +#' given half the room they really have. +#' @param width width of the canvas in pixels +#' @param text.size size of the condition labels +#' @param text.angle angle of the condition labels +#' @param condition.label.adjust if FALSE, keep the in-panel layout unchanged +#' +#' @return list with `use_facets`, and when that is TRUE the `wrap_chars` +#' width to wrap strip labels at and the `strip_size` to draw them at +#' @keywords internal +.layoutConditionLabels = function( + groupName, n_facets, width, text.size, text.angle, + condition.label.adjust = TRUE +) { + inline = list(use_facets = FALSE, wrap_chars = NA_integer_, + strip_size = NA_real_) + if (!isTRUE(condition.label.adjust)) { + return(inline) + } + # A non-zero text.angle is a deliberate choice by the caller. Honour it and + # leave the layout alone rather than overriding it. + if (!isTRUE(all.equal(as.numeric(text.angle), 0))) { + return(inline) + } + n_conditions = nrow(groupName) + if (n_conditions < 2L || !is.numeric(width) || width <= 0) { + return(inline) + } + # Horizontal room per condition. The canvas is width/72 inches (savePlot() + # converts the same way); ~1.1in of it goes to the y-axis title, tick labels + # and margins, and what is left is split across the facets. + panel_in = (width / 72 - 1.1) / max(n_facets, 1L) + slot_in = panel_in / n_conditions + if (slot_in <= 0) { + return(inline) + } + # Label width is estimated from nchar, not measured. grid::stringWidth() is + # exact but needs an open graphics device, which is not available while the + # plot is being built -- measuring would make the result device-dependent + # and this function untestable. 0.53 em per character is calibrated against + # graphics::strwidth() on the reproduction data and lands within ~7%. + char_in = text.size * ggplot2::.pt * 0.53 / 72 + if (max(nchar(as.character(groupName$Name))) * char_in < slot_in) { + # The name is narrower than its slot. Every dataset that renders + # correctly today takes this path and is left exactly as it was. + return(inline) + } + # Size the strip text to the room a strip actually has. strwrap() cannot + # break inside a word, so the binding constraint is the longest unbreakable + # token, not the longest name: pick the largest font at which that token + # still fits its slot. Bounded above by the caller's text.size (a strip + # should never shout louder than the in-panel labels it replaces) and below + # by 4pt, past which shrinking buys illegibility rather than fit. + tokens = unlist(strsplit(gsub("([_.])", "\\1 ", as.character(groupName$Name)), + "[[:space:]]+"), FALSE, FALSE) + longest_token = max(nchar(tokens), 1L) + fitted_pt = slot_in * 72 / (longest_token * 0.53) + strip_size = max(4, min(text.size * ggplot2::.pt, fitted_pt)) + # Wrap at the size the strip will actually be drawn at, not the in-panel one. + strip_char_in = strip_size * 0.53 / 72 + list(use_facets = TRUE, + wrap_chars = max(1L, floor(slot_in / strip_char_in)), + strip_size = strip_size) +} + + +#' Wrap condition names for use as facet strip labels +#' +#' `strwrap()` breaks only at whitespace and condition names are usually +#' underscore-delimited, so separators are turned into break opportunities and +#' the injected spaces are removed again afterwards. The lines therefore rejoin +#' to the original name at any wrap width. +#' +#' @param x character vector of condition names +#' @param wrap_chars width to wrap at, in characters +#' +#' @return character vector with newlines inserted +#' @keywords internal +.wrapConditionLabels = function(x, wrap_chars) { + vapply(as.character(x), function(nm) { + parts = strwrap(gsub("([_.])", "\\1 ", nm), width = wrap_chars + 1L) + paste(gsub("([_.]) ", "\\1", parts), collapse = "\n") + }, character(1), USE.NAMES = FALSE) +} + + +#' Facet, separator and label pieces for one condition layout +#' +#' Returns the parts that differ between the in-panel layout and the facet +#' layout so the plot builders can add them unconditionally: ggplot2 treats the +#' addition of NULL as a no-op, which keeps one pipeline instead of two. +#' +#' @param layout result of `.layoutConditionLabels()` +#' @param groupName data.frame of label positions, for the in-panel layout +#' @param lineNameAxis positions of the condition separator lines +#' @param text.size size of the in-panel condition labels +#' @param text.angle angle of the in-panel condition labels +#' @param x.axis.size size of the x axis text +#' +#' @return list of ggplot2 objects or NULL, named facet, vline, text and strip +#' @keywords internal +.conditionLayoutSpecs = function(layout, groupName, lineNameAxis, text.size, + text.angle, x.axis.size) { + GROUP = LABEL = NULL + + if (!isTRUE(layout$use_facets)) { + return(list( + facet = facet_grid(~LABEL), + vline = geom_vline(xintercept = lineNameAxis + 0.5, colour = "grey", + linetype = "longdash"), + text = geom_text(data = groupName, + aes(x = .data$RUN, y = .data$ABUNDANCE, + label = .data$Name), + size = text.size, angle = text.angle, + color = "black"), + strip = NULL)) + } + # The dashed separators and the in-panel names are exactly what the facet + # replaces, so both are dropped rather than drawn twice. + list( + facet = facet_grid(LABEL ~ GROUP, scales = "free_x", space = "free_x", + labeller = labeller(GROUP = function(x) + .wrapConditionLabels(x, layout$wrap_chars))), + vline = NULL, + text = NULL, + strip = theme(strip.text.x = element_text(size = layout$strip_size), + panel.spacing.x = unit(1.5, "pt"), + axis.text.x = element_text(size = max(x.axis.size - 5, 4)))) +} + + #' Create profile plot #' @inheritParams dataProcessPlots #' @param input data.table @@ -41,7 +199,9 @@ .makeProfilePlot = function( input, is_censored, featureName, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, - ss, s, cumGroupAxis, yaxis.name, lineNameAxis, groupNametemp, dot_colors + ss, s, cumGroupAxis, yaxis.name, lineNameAxis, groupNametemp, dot_colors, + legend.position = "top", legend.ncol = NULL, max.legend.entries = 30, + width = 800, condition.label.adjust = TRUE ) { RUN = ABUNDANCE = Name = NULL @@ -56,9 +216,50 @@ type_color = "PEPTIDE" } + # ggplot2 allocates legend space before panel space, and the number of + # features per protein is unbounded, so a large legend can shrink the panel + # to nothing: DIARawData's 149-feature protein renders as a full page of + # legend with no plot on it at all. Past `max.legend.entries` the colour key + # is dropped and the count moves into the title, so the loss is stated + # rather than silent -- the plotly path already loses entries silently, and + # reproducing that would be no fix. + # + # Why 30: over 105 proteins of a real Spectronaut export (median 11 features + # per protein, p90 37, max 107) a cap of 30 leaves ~86% of proteins + # untouched; 40 leaves ~91%, 50 leaves ~95%. The comparison is strict, so 30 + # entries keep the legend and 31 lose it. + # + # The counter-argument, reviewed and rejected 2026-08-30: at the boundary the + # legend is not yet doing harm. DDARawData's "rabbit" (31 features) renders a + # readable panel *with* its legend; omitting it buys ~44% panel height and + # costs all 31 feature identities, so 40 or 50 would also have been + # defensible. 30 was kept deliberately. If you are here because a reviewer + # asked to raise it, the numbers above are the argument -- it is a one-line + # change and nothing else depends on the value. + # + # Note the cap rarely binds on the plotly path: ggplotly() flattens the + # legend to one column and truncates it to ~10 entries before the cap is + # reached. There the title note, not the cap, is what fixes the bug. + n_legend_entries = data.table::uniqueN(input[[type_color]]) + omit_legend = featureName != "NA" && is.numeric(max.legend.entries) && + n_legend_entries > max.legend.entries + # as.character matters: PROTEIN is a factor, and ggplotly() renders a factor + # title as its level index ("1") rather than the protein name. + plot_title = as.character(unique(input$PROTEIN)) + if (omit_legend) { + plot_title = paste0(plot_title, " (", n_legend_entries, + " features; legend omitted)") + } + legend_ncol = if (is.null(legend.ncol)) 3 else legend.ncol + + cond_specs = .conditionLayoutSpecs( + .layoutConditionLabels(groupNametemp, length(unique(input$LABEL)), width, + text.size, text.angle, condition.label.adjust), + groupNametemp, lineNameAxis, text.size, text.angle, x.axis.size) + profile_plot = ggplot(data = input, aes(x = .data$RUN, y = .data$newABUNDANCE, color = .data[[type_color]], linetype = .data$FEATURE)) + - facet_grid(~LABEL) + + cond_specs$facet + geom_line(linewidth = 0.5) if (is_censored) { @@ -95,13 +296,12 @@ profile_plot = profile_plot + scale_x_continuous('MS runs', breaks = cumGroupAxis) + scale_y_continuous(yaxis.name, limits = c(y.limdown, y.limup)) + - geom_vline(xintercept = lineNameAxis + 0.5, colour = "grey", linetype = "longdash") + - labs(title = unique(input$PROTEIN)) + - geom_text(data = groupNametemp, aes(x = .data$RUN, y = .data$ABUNDANCE, label = .data$Name), - size = text.size, - angle = text.angle, - color = "black") + - theme_msstats("PROFILEPLOT", x.axis.size, y.axis.size, legend.size) + cond_specs$vline + + labs(title = plot_title) + + cond_specs$text + + theme_msstats("PROFILEPLOT", x.axis.size, y.axis.size, legend.size, + legend_position = legend.position) + + cond_specs$strip if (featureName == "TRANSITION") { color_guide = guide_legend(order=1, @@ -112,7 +312,7 @@ keywidth = 0.25, keyheight = 0.1, default.unit = 'inch', - ncol = 3) + ncol = legend_ncol) } else if (featureName == "PEPTIDE") { color_guide = guide_legend(order=1, title = paste("# peptide:", nlevels(input$PEPTIDE)), @@ -120,7 +320,7 @@ keywidth = 0.25, keyheight = 0.1, default.unit = 'inch', - ncol = 3) + ncol = legend_ncol) } shape_guide = guide_legend(order=2, title = NULL, @@ -128,9 +328,17 @@ keywidth = 0.1, keyheight = 0.1, default.unit = 'inch') - if (is_censored) { + if (omit_legend) { + # Keep the (small) censoring legend; only the per-feature colour key goes. + if (is_censored) { + profile_plot = profile_plot + guides(color = "none", + shape = shape_guide) + } else { + profile_plot = profile_plot + guides(color = "none") + } + } else if (is_censored) { if (featureName == "NA") { - profile_plot = profile_plot + guides(color = FALSE, + profile_plot = profile_plot + guides(color = "none", shape = shape_guide) } else { profile_plot = profile_plot + guides(color = color_guide, @@ -150,16 +358,22 @@ .makeSummaryProfilePlot = function( input, is_censored, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, cumGroupAxis, - yaxis.name, lineNameAxis, groupNametemp + yaxis.name, lineNameAxis, groupNametemp, legend.position = "top", + width = 800, condition.label.adjust = TRUE ) { RUN = ABUNDANCE = Name = NULL + cond_specs = .conditionLayoutSpecs( + .layoutConditionLabels(groupNametemp, length(unique(input$LABEL)), width, + text.size, text.angle, condition.label.adjust), + groupNametemp, lineNameAxis, text.size, text.angle, x.axis.size) + num_features = data.table::uniqueN(input$FEATURE) profile_plot = ggplot(data = input, aes(x = .data$RUN, y = .data$newABUNDANCE, color = .data$analysis, linetype = .data$FEATURE, size = .data$analysis)) + - facet_grid(~LABEL) + + cond_specs$facet + geom_line(linewidth = 0.5) if (is_censored) { # splitting into two layers to keep red above grey @@ -196,15 +410,13 @@ guide = "none") + scale_x_continuous("MS runs", breaks = cumGroupAxis) + scale_y_continuous(yaxis.name, limits = c(y.limdown, y.limup)) + - geom_vline(xintercept = lineNameAxis + 0.5, - colour = "grey", linetype = "longdash") + + cond_specs$vline + labs(title = unique(input$PROTEIN)) + - geom_text(data = groupNametemp, aes(x = .data$RUN, y = .data$ABUNDANCE, label = .data$Name), - size = text.size, - angle = text.angle, - color = "black") + + cond_specs$text + theme_msstats("PROFILEPLOT", x.axis.size, y.axis.size, - legend.size, legend.title = element_blank()) + legend.size, legend_position = legend.position, + legend.title = element_blank()) + + cond_specs$strip color_guide = guide_legend(order = 1, title = NULL, label.theme = element_text(size = 10, angle = 0)) @@ -232,10 +444,15 @@ .makeQCPlot = function( input, all_proteins, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, label.color, cumGroupAxis, groupName, - lineNameAxis, yaxis.name + lineNameAxis, yaxis.name, width = 800, condition.label.adjust = TRUE ) { RUN = ABUNDANCE = Name = NULL + cond_specs = .conditionLayoutSpecs( + .layoutConditionLabels(groupName, length(unique(input$LABEL)), width, + text.size, text.angle, condition.label.adjust), + groupName, lineNameAxis, text.size, text.angle, x.axis.size) + if (all_proteins) { plot_title = "All" } else { @@ -243,19 +460,18 @@ } ggplot(input, aes(x = .data$RUN, y = .data$ABUNDANCE)) + - facet_grid(~LABEL) + + cond_specs$facet + geom_boxplot(aes(fill = .data$LABEL), outlier.shape = 1, outlier.size = 1.5) + scale_fill_manual(values = label.color, guide = "none") + scale_x_discrete("MS runs", breaks = cumGroupAxis) + scale_y_continuous(yaxis.name, limits = c(y.limdown, y.limup)) + - geom_vline(xintercept = lineNameAxis + 0.5, colour = "grey", - linetype = "longdash") + + cond_specs$vline + labs(title = plot_title) + - geom_text(data = groupName, aes(x = .data$RUN, y = .data$ABUNDANCE, label = .data$Name), - size = text.size, angle = text.angle, color = "black") + + cond_specs$text + theme_msstats("QCPLOT", x.axis.size, y.axis.size, - legend_size = NULL) + legend_size = NULL) + + cond_specs$strip } diff --git a/inst/NEWS.rd b/inst/NEWS.rd index a7422072..fdc80632 100644 --- a/inst/NEWS.rd +++ b/inst/NEWS.rd @@ -2,6 +2,15 @@ \title{News for package, \pkg{MSstats}} \encoding{UTF-8} +\section{Version 4.21.1 (2026-08-30)}{ + \itemize{ + \item \strong{Profile plots}: Proteins with many features no longer lose the data panel to an oversized feature legend. Past \code{max.legend.entries} (default 30) the feature legend is omitted and the feature count is appended to the plot title, so the omission is stated rather than silent. Previously a 149-feature protein rendered as a full page of legend with no plot on it at all, and on the Plotly path the legend was silently truncated to roughly ten entries. + \item \strong{Profile plots}: New \code{legend.position} and \code{legend.ncol} arguments to \code{dataProcessPlots} reposition and resize the feature legend. \code{legend.position} is honoured in the Plotly output as well as the PDF output. + \item \strong{Profile and QC plots}: Condition labels no longer overlap when condition names are long. Where a name is wider than the space available to it, the condition is drawn as a facet strip with the name wrapped to fit, instead of being printed inside the panel. Plots whose labels already fit render identically to before. Set \code{condition.label.adjust = FALSE} to restore the previous layout in all cases. + \item \strong{Note}: \code{text.angle} affects the ggplot2 (PDF) output only. Text rotation is not carried through by \code{ggplotly}, so it has never had an effect when \code{isPlotly = TRUE}. + } +} + \section{Version 4.20.0 (2026-04-23)}{ \itemize{ \item \strong{Protein turnover analysis}: Added support for multi-label summarization, enabling experiments that use multiple isotope labels to quantify protein synthesis and degradation rates. Each isotope label is now summarized independently, giving more accurate per-label abundance estimates. diff --git a/inst/tinytest/test_utils_dataprocess_plots.R b/inst/tinytest/test_utils_dataprocess_plots.R new file mode 100644 index 00000000..324e7d8f --- /dev/null +++ b/inst/tinytest/test_utils_dataprocess_plots.R @@ -0,0 +1,176 @@ +# Condition label layout ------------------------------------------------------ +# +# .layoutConditionLabels() decides whether condition names still fit inside the +# panel. Crowding is name width relative to the slot a condition gets, which is +# panel_width / n_conditions -- runs per condition do not affect it. + +mk_group_name = function(nms) { + data.frame(RUN = seq_along(nms), ABUNDANCE = rep(20, length(nms)), + Name = nms, stringsAsFactors = FALSE) +} +layout_of = function(nms, n_facets = 2, width = 800, text.size = 4, + text.angle = 0, adjust = TRUE) { + MSstats:::.layoutConditionLabels(mk_group_name(nms), n_facets, width, + text.size, text.angle, adjust) +} +long_names = paste0("Timepoint_", sprintf("%02d", 1:10), "_Treated_High_Dose") + +# Test 1: short names keep the in-panel layout (the no-regression path) +expect_false(layout_of(as.character(1:10))$use_facets) + +# Test 2: eight short names over two facets stay in-panel. This is the shape of +# the dataset the original report pointed at, which renders correctly today. +expect_false(layout_of(c("C1", "C2", "C3", "C4", "D1", "D2", "D3", "D4"))$use_facets) + +# Test 3: long names switch to facet strips +expect_true(layout_of(long_names)$use_facets) + +# Test 4: an explicit text.angle is the caller's choice, so leave the layout be +expect_false(layout_of(long_names, text.angle = 45)$use_facets) + +# Test 5: the opt-out is honoured even when crowded +expect_false(layout_of(long_names, adjust = FALSE)$use_facets) + +# Test 6: a single condition cannot collide with anything +expect_false(layout_of("A_Single_Very_Long_Condition_Name")$use_facets) + +# Test 7: a degenerate canvas width must not error +expect_false(layout_of(long_names, width = 0)$use_facets) + +# Test 8: two facets halve the panel, so wrapping is at least as tight as one +expect_true(layout_of(long_names, n_facets = 2)$wrap_chars <= + layout_of(long_names, n_facets = 1)$wrap_chars) + +# Test 9: more conditions means less room, so a smaller strip font +many = paste0("Timepoint_", sprintf("%02d", 1:20), "_Treated_High_Dose") +expect_true(layout_of(many)$strip_size < layout_of(long_names)$strip_size) + +# Test 10: a strip never shouts louder than the in-panel label it replaces +expect_true(layout_of(long_names, text.size = 2)$strip_size <= 2 * ggplot2::.pt) + +# Test 11: the strip font is floored rather than shrunk into illegibility +crowded = paste0("Condition_", sprintf("%02d", 1:60), "_Treated_High_Dose") +expect_true(layout_of(crowded)$strip_size >= 4) + +# Condition label wrapping ---------------------------------------------------- + +# Test 12: wrapping is lossless at every width, not just the one in use. +# strwrap() breaks on whitespace, so separators are turned into break points and +# the injected spaces removed again; the lines must rejoin to the original name. +lossy_widths = Filter(function(w) { + any(gsub("\n", "", MSstats:::.wrapConditionLabels(long_names, w), + fixed = TRUE) != long_names) +}, 4:40) +expect_equal(length(lossy_widths), 0L) + +# Test 13: wrapping does insert breaks when the name cannot fit on one line +expect_true(any(grepl("\n", MSstats:::.wrapConditionLabels(long_names, 11), + fixed = TRUE))) + +# Test 14: a name that already fits is returned untouched +expect_equal(MSstats:::.wrapConditionLabels("Ctrl", 20), "Ctrl") + +# Profile plot construction --------------------------------------------------- + +prep_profile = function(raw) { + quant = dataProcess(raw, use_log_file = FALSE) + processed = data.table::as.data.table(quant$FeatureLevelData) + summarized = data.table::as.data.table(quant$ProteinLevelData) + processed$PROTEIN = factor(processed$PROTEIN) + summarized$Protein = factor(summarized$Protein) + list(processed = processed, summarized = summarized) +} +# .plotProfile() returns its ggplot objects only when isPlotly = TRUE; the +# conversion to plotly happens later, in dataProcessPlots(), so these are ggplots. +build_profile = function(d, protein, featureName = "Transition", + legend.position = "top", legend.ncol = NULL, + max.legend.entries = 30, condition.label.adjust = TRUE) { + plots = MSstats:::.plotProfile( + d$processed, d$summarized, featureName, FALSE, FALSE, 10, 10, 4, 0, 7, 2, + 800, 600, protein, TRUE, FALSE, FALSE, FALSE, TRUE, + legend.position, legend.ncol, max.legend.entries, condition.label.adjust) + plots[["original_plot"]][["plot 1"]] +} +colour_guide_of = function(p) { + guides = p$guides$guides + if (!is.null(guides$colour)) guides$colour else guides$color +} +has_geom = function(p, cls) { + any(vapply(p$layers, function(l) inherits(l$geom, cls), logical(1))) +} + +grDevices::pdf(NULL) # .plotProfile() print()s each plot; swallow the output +dia = prep_profile(DIARawData) +big_protein = "RNA helicase exp9" # 149 transitions, the worst bundled case + +# Test 15: past max.legend.entries the feature legend is dropped +expect_true(is.null(colour_guide_of(build_profile(dia, big_protein))) || + identical(colour_guide_of(build_profile(dia, big_protein)), "none")) + +# Test 16: and the count is moved into the title, so the loss is not silent +expect_true(grepl("149 features; legend omitted", + build_profile(dia, big_protein)$labels$title, fixed = TRUE)) + +# Test 17: the comparison is strict -- a protein exactly at the cap keeps its legend +expect_false(grepl("legend omitted", + build_profile(dia, big_protein, max.legend.entries = 149)$labels$title)) + +# Test 18: one below the count, and it is dropped +expect_true(grepl("legend omitted", + build_profile(dia, big_protein, max.legend.entries = 148)$labels$title)) + +# Test 19: an infinite cap never omits +expect_false(grepl("legend omitted", + build_profile(dia, big_protein, max.legend.entries = Inf)$labels$title)) + +# Test 20: featureName = "NA" is the pre-existing full suppression, and must not +# gain an omission note for a legend the user asked not to have +na_plot = build_profile(dia, big_protein, featureName = "NA") +expect_false(grepl("legend omitted", na_plot$labels$title)) + +# Test 21: the title is the protein name, not a factor level index. PROTEIN is a +# factor and ggplotly() renders a factor title as "1" unless it is coerced. +expect_true(grepl("RNA", build_profile(dia, big_protein)$labels$title)) + +# Test 22: legend.position reaches the theme +expect_equal(build_profile(dia, big_protein, legend.position = "right")$theme$legend.position, + "right") + +# Test 23: legend.ncol overrides the default of three columns +expect_equal(colour_guide_of(build_profile(dia, "FabG", legend.ncol = 5, + max.legend.entries = Inf))$params$ncol, 5) + +# Test 24: and the default is still three +expect_equal(colour_guide_of(build_profile(dia, "FabG", + max.legend.entries = Inf))$params$ncol, 3) + +# Facet layout for crowded condition names ------------------------------------ + +srm_short = prep_profile(SRMRawData) +srm_raw_long = SRMRawData +srm_raw_long$Condition = long_names[as.integer(as.character(srm_raw_long$Condition))] +srm_long = prep_profile(srm_raw_long) + +short_plot = build_profile(srm_short, "IDHC") +long_plot = build_profile(srm_long, "IDHC") + +# Test 25: uncrowded labels keep the in-panel geom_text layer +expect_true(has_geom(short_plot, "GeomText")) + +# Test 26: and still render both isotope label panels +expect_equal(length(unique(ggplot2::ggplot_build(short_plot)$data[[1]]$PANEL)), 2L) + +# Test 27: crowded labels become a facet on condition instead +expect_true("GROUP" %in% names(ggplot2::ggplot_build(long_plot)$layout$layout)) + +# Test 28: the facet replaces the in-panel text rather than doubling up with it +expect_false(has_geom(long_plot, "GeomText")) + +# Test 29: the dashed condition separators go with it +expect_false(has_geom(long_plot, "GeomVline")) + +# Test 30: opting out keeps the in-panel layout even when crowded +expect_true(has_geom(build_profile(srm_long, "IDHC", + condition.label.adjust = FALSE), "GeomText")) + +grDevices::dev.off() diff --git a/man/dataProcessPlots.Rd b/man/dataProcessPlots.Rd index 2d1f08ca..1cb4f6a0 100644 --- a/man/dataProcessPlots.Rd +++ b/man/dataProcessPlots.Rd @@ -27,7 +27,11 @@ dataProcessPlots( save_condition_plot_result = FALSE, remove_uninformative_feature_outlier = FALSE, address = "", - isPlotly = FALSE + isPlotly = FALSE, + legend.position = "top", + legend.ncol = NULL, + max.legend.entries = 30, + condition.label.adjust = TRUE ) } \arguments{ @@ -70,7 +74,11 @@ graph in Profile Plot and QC plot. Default is 4.} \item{text.angle}{angle of labels represented each condition at the top of graph in Profile Plot and QC plot or x-axis labeling in Condition plot. -Default is 0.} +Default is 0. This affects the ggplot2 (PDF) output only: text rotation is +not carried through to the Plotly output by \code{ggplotly}, so it has no +effect when \code{isPlotly = TRUE}. Setting a non-zero value also disables +the automatic condition label layout described in +\code{condition.label.adjust}.} \item{legend.size}{size of feature legend (transition-level or peptide-level) above graph in Profile Plot. Default is 7.} @@ -115,6 +123,30 @@ The other assigned folder has to be existed under the current working directory. The command address can help to specify where to store the file as well as how to modify the beginning of the file name. If address=FALSE, plot will be not saved as pdf file but showed in window.} + +\item{legend.position}{position of the feature legend in Profile Plot: "top" +(default), "bottom", "left", "right", or "none" to hide it. Honoured in both +the ggplot2 (PDF) and the Plotly output.} + +\item{legend.ncol}{number of columns in the feature legend of Profile Plot. +Default is NULL, which uses three columns.} + +\item{max.legend.entries}{maximum number of features drawn in the Profile Plot +feature legend. Default is 30. A protein with more features than this is drawn +without the feature legend, and the feature count is appended to the plot +title, for example "P12345 (149 features; legend omitted)", so that the +omission is visible rather than silent. Use \code{Inf} to always draw the +legend. \code{featureName = "NA"} still suppresses the legend outright. +Note that on the Plotly path \code{ggplotly} truncates long legends before +this limit is reached, so there the title note rather than the limit is what +reports the omission.} + +\item{condition.label.adjust}{TRUE (default) lays out the condition labels of +Profile Plot and QC Plot automatically. When a condition name is wider than +the horizontal space available to it, the condition is drawn as a facet strip +and the name is wrapped to fit, rather than being printed inside the panel +where it would overlap its neighbours. Plots whose condition labels already +fit are left unchanged. FALSE reproduces the previous layout exactly.} } \description{ To illustrate the quantitative data after data-preprocessing and diff --git a/man/dot-conditionLayoutSpecs.Rd b/man/dot-conditionLayoutSpecs.Rd new file mode 100644 index 00000000..94989f34 --- /dev/null +++ b/man/dot-conditionLayoutSpecs.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_dataprocess_plots.R +\name{.conditionLayoutSpecs} +\alias{.conditionLayoutSpecs} +\title{Facet, separator and label pieces for one condition layout} +\usage{ +.conditionLayoutSpecs( + layout, + groupName, + lineNameAxis, + text.size, + text.angle, + x.axis.size +) +} +\arguments{ +\item{layout}{result of `.layoutConditionLabels()`} + +\item{groupName}{data.frame of label positions, for the in-panel layout} + +\item{lineNameAxis}{positions of the condition separator lines} + +\item{text.size}{size of the in-panel condition labels} + +\item{text.angle}{angle of the in-panel condition labels} + +\item{x.axis.size}{size of the x axis text} +} +\value{ +list of ggplot2 objects or NULL, named facet, vline, text and strip +} +\description{ +Returns the parts that differ between the in-panel layout and the facet +layout so the plot builders can add them unconditionally: ggplot2 treats the +addition of NULL as a no-op, which keeps one pipeline instead of two. +} +\keyword{internal} diff --git a/man/dot-layoutConditionLabels.Rd b/man/dot-layoutConditionLabels.Rd new file mode 100644 index 00000000..9d58b30f --- /dev/null +++ b/man/dot-layoutConditionLabels.Rd @@ -0,0 +1,60 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_dataprocess_plots.R +\name{.layoutConditionLabels} +\alias{.layoutConditionLabels} +\title{Decide how condition labels should be laid out} +\usage{ +.layoutConditionLabels( + groupName, + n_facets, + width, + text.size, + text.angle, + condition.label.adjust = TRUE +) +} +\arguments{ +\item{groupName}{data.frame of RUN, ABUNDANCE and Name, as built by +`.plotProfile()` and `.plotQC()`} + +\item{n_facets}{number of facet panels actually drawn. Pass +`length(unique(input$LABEL))`, not `nlevels()`: LABEL is a factor over the +whole table, so `nlevels()` reports 2 for a protein carrying only one label +while `facet_grid()` draws a single panel, and the labels would then be +given half the room they really have.} + +\item{width}{width of the canvas in pixels} + +\item{text.size}{size of the condition labels} + +\item{text.angle}{angle of the condition labels} + +\item{condition.label.adjust}{if FALSE, keep the in-panel layout unchanged} +} +\value{ +list with `use_facets`, and when that is TRUE the `wrap_chars` + width to wrap strip labels at and the `strip_size` to draw them at +} +\description{ +Condition names are drawn inside the panel by `geom_text()`, one per +condition, and conditions tile the panel evenly. Each name gets +`panel_width / n_conditions` of horizontal room no matter how many runs it +contains -- runs per condition do not affect label spacing at all, which is +why crowding is measured as name width relative to that slot and never as a +sample count. +} +\details{ +Once a name is wider than its slot, the in-panel layout cannot be rescued by +moving text about. Rotation does not survive `ggplotly()` and MSstatsShiny +renders through it; `ggrepel` drops labels outright for the same reason; +shrinking the font enough to fit 30-character names reaches ~2pt, which is +unreadable. Wrapping and staggering inside the panel do make the names +legible, but only by printing them on top of the data. + +So past the threshold the condition becomes a facet instead, and ggplot2 +reserves a strip band for it -- collision with the data and clipping at the +panel edge stop being possible rather than being mitigated. Verified to +survive `ggplotly()`: the strips, `space = "free_x"` sizing, and newline +wrapping (which is translated to `
`). +} +\keyword{internal} diff --git a/man/dot-makeConditionPlot.Rd b/man/dot-makeConditionPlot.Rd index dbc11a37..f927bce0 100644 --- a/man/dot-makeConditionPlot.Rd +++ b/man/dot-makeConditionPlot.Rd @@ -39,7 +39,11 @@ graph in Profile Plot and QC plot. Default is 4.} \item{text.angle}{angle of labels represented each condition at the top of graph in Profile Plot and QC plot or x-axis labeling in Condition plot. -Default is 0.} +Default is 0. This affects the ggplot2 (PDF) output only: text rotation is +not carried through to the Plotly output by \code{ggplotly}, so it has no +effect when \code{isPlotly = TRUE}. Setting a non-zero value also disables +the automatic condition label layout described in +\code{condition.label.adjust}.} \item{legend.size}{size of feature legend (transition-level or peptide-level) above graph in Profile Plot. Default is 7.} diff --git a/man/dot-makeProfilePlot.Rd b/man/dot-makeProfilePlot.Rd index c259fa28..8c38af92 100644 --- a/man/dot-makeProfilePlot.Rd +++ b/man/dot-makeProfilePlot.Rd @@ -22,7 +22,12 @@ yaxis.name, lineNameAxis, groupNametemp, - dot_colors + dot_colors, + legend.position = "top", + legend.ncol = NULL, + max.legend.entries = 30, + width = 800, + condition.label.adjust = TRUE ) } \arguments{ @@ -44,12 +49,42 @@ graph in Profile Plot and QC plot. Default is 4.} \item{text.angle}{angle of labels represented each condition at the top of graph in Profile Plot and QC plot or x-axis labeling in Condition plot. -Default is 0.} +Default is 0. This affects the ggplot2 (PDF) output only: text rotation is +not carried through to the Plotly output by \code{ggplotly}, so it has no +effect when \code{isPlotly = TRUE}. Setting a non-zero value also disables +the automatic condition label layout described in +\code{condition.label.adjust}.} \item{legend.size}{size of feature legend (transition-level or peptide-level) above graph in Profile Plot. Default is 7.} \item{dot.size.profile}{size of dots in profile plot. Default is 2.} + +\item{legend.position}{position of the feature legend in Profile Plot: "top" +(default), "bottom", "left", "right", or "none" to hide it. Honoured in both +the ggplot2 (PDF) and the Plotly output.} + +\item{legend.ncol}{number of columns in the feature legend of Profile Plot. +Default is NULL, which uses three columns.} + +\item{max.legend.entries}{maximum number of features drawn in the Profile Plot +feature legend. Default is 30. A protein with more features than this is drawn +without the feature legend, and the feature count is appended to the plot +title, for example "P12345 (149 features; legend omitted)", so that the +omission is visible rather than silent. Use \code{Inf} to always draw the +legend. \code{featureName = "NA"} still suppresses the legend outright. +Note that on the Plotly path \code{ggplotly} truncates long legends before +this limit is reached, so there the title note rather than the limit is what +reports the omission.} + +\item{width}{width of the saved file in pixels. Default is 800 pixels.} + +\item{condition.label.adjust}{TRUE (default) lays out the condition labels of +Profile Plot and QC Plot automatically. When a condition name is wider than +the horizontal space available to it, the condition is drawn as a facet strip +and the name is wrapped to fit, rather than being printed inside the panel +where it would overlap its neighbours. Plots whose condition labels already +fit are left unchanged. FALSE reproduces the previous layout exactly.} } \description{ Create profile plot diff --git a/man/dot-makeQCPlot.Rd b/man/dot-makeQCPlot.Rd index 98b17d6c..7a1df1ad 100644 --- a/man/dot-makeQCPlot.Rd +++ b/man/dot-makeQCPlot.Rd @@ -18,7 +18,9 @@ cumGroupAxis, groupName, lineNameAxis, - yaxis.name + yaxis.name, + width = 800, + condition.label.adjust = TRUE ) } \arguments{ @@ -36,10 +38,23 @@ graph in Profile Plot and QC plot. Default is 4.} \item{text.angle}{angle of labels represented each condition at the top of graph in Profile Plot and QC plot or x-axis labeling in Condition plot. -Default is 0.} +Default is 0. This affects the ggplot2 (PDF) output only: text rotation is +not carried through to the Plotly output by \code{ggplotly}, so it has no +effect when \code{isPlotly = TRUE}. Setting a non-zero value also disables +the automatic condition label layout described in +\code{condition.label.adjust}.} \item{legend.size}{size of feature legend (transition-level or peptide-level) above graph in Profile Plot. Default is 7.} + +\item{width}{width of the saved file in pixels. Default is 800 pixels.} + +\item{condition.label.adjust}{TRUE (default) lays out the condition labels of +Profile Plot and QC Plot automatically. When a condition name is wider than +the horizontal space available to it, the condition is drawn as a facet strip +and the name is wrapped to fit, rather than being printed inside the panel +where it would overlap its neighbours. Plots whose condition labels already +fit are left unchanged. FALSE reproduces the previous layout exactly.} } \description{ To illustrate the quantitative data after data-preprocessing and diff --git a/man/dot-makeSummaryProfilePlot.Rd b/man/dot-makeSummaryProfilePlot.Rd index 94a2f935..2f503dc3 100644 --- a/man/dot-makeSummaryProfilePlot.Rd +++ b/man/dot-makeSummaryProfilePlot.Rd @@ -18,7 +18,10 @@ cumGroupAxis, yaxis.name, lineNameAxis, - groupNametemp + groupNametemp, + legend.position = "top", + width = 800, + condition.label.adjust = TRUE ) } \arguments{ @@ -36,12 +39,29 @@ graph in Profile Plot and QC plot. Default is 4.} \item{text.angle}{angle of labels represented each condition at the top of graph in Profile Plot and QC plot or x-axis labeling in Condition plot. -Default is 0.} +Default is 0. This affects the ggplot2 (PDF) output only: text rotation is +not carried through to the Plotly output by \code{ggplotly}, so it has no +effect when \code{isPlotly = TRUE}. Setting a non-zero value also disables +the automatic condition label layout described in +\code{condition.label.adjust}.} \item{legend.size}{size of feature legend (transition-level or peptide-level) above graph in Profile Plot. Default is 7.} \item{dot.size.profile}{size of dots in profile plot. Default is 2.} + +\item{legend.position}{position of the feature legend in Profile Plot: "top" +(default), "bottom", "left", "right", or "none" to hide it. Honoured in both +the ggplot2 (PDF) and the Plotly output.} + +\item{width}{width of the saved file in pixels. Default is 800 pixels.} + +\item{condition.label.adjust}{TRUE (default) lays out the condition labels of +Profile Plot and QC Plot automatically. When a condition name is wider than +the horizontal space available to it, the condition is drawn as a facet strip +and the name is wrapped to fit, rather than being printed inside the panel +where it would overlap its neighbours. Plots whose condition labels already +fit are left unchanged. FALSE reproduces the previous layout exactly.} } \description{ Make summary profile plot diff --git a/man/dot-wrapConditionLabels.Rd b/man/dot-wrapConditionLabels.Rd new file mode 100644 index 00000000..b2c986d1 --- /dev/null +++ b/man/dot-wrapConditionLabels.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_dataprocess_plots.R +\name{.wrapConditionLabels} +\alias{.wrapConditionLabels} +\title{Wrap condition names for use as facet strip labels} +\usage{ +.wrapConditionLabels(x, wrap_chars) +} +\arguments{ +\item{x}{character vector of condition names} + +\item{wrap_chars}{width to wrap at, in characters} +} +\value{ +character vector with newlines inserted +} +\description{ +`strwrap()` breaks only at whitespace and condition names are usually +underscore-delimited, so separators are turned into break opportunities and +the injected spaces are removed again afterwards. The lines therefore rejoin +to the original name at any wrap width. +} +\keyword{internal} From 88a0f1d4965cab7d0862c62454fabca185d2f95e Mon Sep 17 00:00:00 2001 From: Rudhik1904 Date: Mon, 31 Aug 2026 02:29:49 -0400 Subject: [PATCH 2/2] Fixing MSShinyUseCase --- R/dataProcessPlots.R | 9 ++-- R/utils_dataprocess_plots.R | 42 +++++++++++----- inst/tinytest/test_utils_dataprocess_plots.R | 50 ++++++++++++++++++++ man/dot-layoutConditionLabels.Rd | 10 +++- man/dot-makeProfilePlot.Rd | 14 +++++- man/dot-makeQCPlot.Rd | 14 +++++- man/dot-makeSummaryProfilePlot.Rd | 14 +++++- 7 files changed, 133 insertions(+), 20 deletions(-) diff --git a/R/dataProcessPlots.R b/R/dataProcessPlots.R index e9a9c22f..19669d27 100644 --- a/R/dataProcessPlots.R +++ b/R/dataProcessPlots.R @@ -387,7 +387,7 @@ dataProcessPlots = function( ss, s, cumGroupAxis, yaxis.name, lineNameAxis, groupNametemp, dot_colors, legend.position, legend.ncol, max.legend.entries, - width, condition.label.adjust) + width, condition.label.adjust, isPlotly) setTxtProgressBar(pb, i) print(profile_plot) @@ -457,7 +457,7 @@ dataProcessPlots = function( combined, is_censored, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, cumGroupAxis, yaxis.name, lineNameAxis, groupNametemp, legend.position, - width, condition.label.adjust + width, condition.label.adjust, isPlotly ) print(profile_plot) setTxtProgressBar(pb, i) @@ -536,7 +536,7 @@ dataProcessPlots = function( qc_plot = .makeQCPlot(processed, TRUE, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, label.color, cumGroupAxis, groupName, lineNameAxis, - yaxis.name, width, condition.label.adjust) + yaxis.name, width, condition.label.adjust, isPlotly) print(qc_plot) plots[[1]] = qc_plot } @@ -559,7 +559,8 @@ dataProcessPlots = function( qc_plot = .makeQCPlot(single_protein, FALSE, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, label.color, cumGroupAxis, groupName, - lineNameAxis, yaxis.name, width, condition.label.adjust) + lineNameAxis, yaxis.name, width, condition.label.adjust, + isPlotly) print(qc_plot) plots[[i+1]] = qc_plot # to accomodate all proteins setTxtProgressBar(pb, i) diff --git a/R/utils_dataprocess_plots.R b/R/utils_dataprocess_plots.R index f19a8199..1ed9566b 100644 --- a/R/utils_dataprocess_plots.R +++ b/R/utils_dataprocess_plots.R @@ -66,13 +66,19 @@ #' @param text.size size of the condition labels #' @param text.angle angle of the condition labels #' @param condition.label.adjust if FALSE, keep the in-panel layout unchanged +#' @param isPlotly TRUE when the plot is bound for `ggplotly()`. The canvas is +#' the same number, but not the same size: on the pdf device `width` is points +#' at 72dpi, while plotly treats it as CSS pixels at 96dpi, which is a quarter +#' less room. Measuring both at 72dpi under-triggers the facet layout in the +#' browser -- an 8-condition SILAC design scores 0.80 on the pdf geometry and +#' 1.11 on the real one, so it stayed in-panel and the labels collided. #' #' @return list with `use_facets`, and when that is TRUE the `wrap_chars` #' width to wrap strip labels at and the `strip_size` to draw them at #' @keywords internal .layoutConditionLabels = function( groupName, n_facets, width, text.size, text.angle, - condition.label.adjust = TRUE + condition.label.adjust = TRUE, isPlotly = FALSE ) { inline = list(use_facets = FALSE, wrap_chars = NA_integer_, strip_size = NA_real_) @@ -88,10 +94,12 @@ if (n_conditions < 2L || !is.numeric(width) || width <= 0) { return(inline) } - # Horizontal room per condition. The canvas is width/72 inches (savePlot() - # converts the same way); ~1.1in of it goes to the y-axis title, tick labels - # and margins, and what is left is split across the facets. - panel_in = (width / 72 - 1.1) / max(n_facets, 1L) + # Horizontal room per condition. On the pdf device the canvas is width/72 + # inches (savePlot() converts the same way); plotly reads the same number as + # CSS pixels at 96dpi. ~1.1in of it goes to the y-axis title, tick labels and + # margins, and what is left is split across the facets. + dpi = if (isTRUE(isPlotly)) 96 else 72 + panel_in = (width / dpi - 1.1) / max(n_facets, 1L) slot_in = panel_in / n_conditions if (slot_in <= 0) { return(inline) @@ -180,7 +188,13 @@ # The dashed separators and the in-panel names are exactly what the facet # replaces, so both are dropped rather than drawn twice. list( - facet = facet_grid(LABEL ~ GROUP, scales = "free_x", space = "free_x", + # scales = "free_x" gives each condition its own run axis. space = + # "free_x" would also size panels by run count, but it computes a + # non-finite panel width when a protein is absent from a condition and + # the x scale is discrete (the QC plot), so grid fails with + # "non-finite location and/or size for viewport". Equal-width panels + # cost nothing here and cannot produce that. + facet = facet_grid(LABEL ~ GROUP, scales = "free_x", labeller = labeller(GROUP = function(x) .wrapConditionLabels(x, layout$wrap_chars))), vline = NULL, @@ -201,7 +215,7 @@ y.axis.size, text.size, text.angle, legend.size, dot.size.profile, ss, s, cumGroupAxis, yaxis.name, lineNameAxis, groupNametemp, dot_colors, legend.position = "top", legend.ncol = NULL, max.legend.entries = 30, - width = 800, condition.label.adjust = TRUE + width = 800, condition.label.adjust = TRUE, isPlotly = FALSE ) { RUN = ABUNDANCE = Name = NULL @@ -254,7 +268,8 @@ cond_specs = .conditionLayoutSpecs( .layoutConditionLabels(groupNametemp, length(unique(input$LABEL)), width, - text.size, text.angle, condition.label.adjust), + text.size, text.angle, condition.label.adjust, + isPlotly), groupNametemp, lineNameAxis, text.size, text.angle, x.axis.size) profile_plot = ggplot(data = input, aes(x = .data$RUN, y = .data$newABUNDANCE, @@ -359,13 +374,14 @@ input, is_censored, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, dot.size.profile, cumGroupAxis, yaxis.name, lineNameAxis, groupNametemp, legend.position = "top", - width = 800, condition.label.adjust = TRUE + width = 800, condition.label.adjust = TRUE, isPlotly = FALSE ) { RUN = ABUNDANCE = Name = NULL cond_specs = .conditionLayoutSpecs( .layoutConditionLabels(groupNametemp, length(unique(input$LABEL)), width, - text.size, text.angle, condition.label.adjust), + text.size, text.angle, condition.label.adjust, + isPlotly), groupNametemp, lineNameAxis, text.size, text.angle, x.axis.size) num_features = data.table::uniqueN(input$FEATURE) @@ -444,13 +460,15 @@ .makeQCPlot = function( input, all_proteins, y.limdown, y.limup, x.axis.size, y.axis.size, text.size, text.angle, legend.size, label.color, cumGroupAxis, groupName, - lineNameAxis, yaxis.name, width = 800, condition.label.adjust = TRUE + lineNameAxis, yaxis.name, width = 800, condition.label.adjust = TRUE, + isPlotly = FALSE ) { RUN = ABUNDANCE = Name = NULL cond_specs = .conditionLayoutSpecs( .layoutConditionLabels(groupName, length(unique(input$LABEL)), width, - text.size, text.angle, condition.label.adjust), + text.size, text.angle, condition.label.adjust, + isPlotly), groupName, lineNameAxis, text.size, text.angle, x.axis.size) if (all_proteins) { diff --git a/inst/tinytest/test_utils_dataprocess_plots.R b/inst/tinytest/test_utils_dataprocess_plots.R index 324e7d8f..40868905 100644 --- a/inst/tinytest/test_utils_dataprocess_plots.R +++ b/inst/tinytest/test_utils_dataprocess_plots.R @@ -174,3 +174,53 @@ expect_true(has_geom(build_profile(srm_long, "IDHC", condition.label.adjust = FALSE), "GeomText")) grDevices::dev.off() + +# Device geometry ------------------------------------------------------------- +# +# `width` is the same number but not the same size on the two paths: the pdf +# device reads 800 as points at 72dpi (11.1in), plotly as CSS pixels at 96dpi +# (8.3in). Measuring the browser at 72dpi under-reports crowding by a quarter, +# which let an 8-condition SILAC design render with colliding labels in +# MSstatsShiny while scoring as comfortable. +silac = c("0hr", "1hr", "4hr", "12hrs", "24hrs", "48hrs", "96hrs", "168hrs") + +# Test 31: on the pdf geometry that design genuinely fits, so it stays in-panel +expect_false(MSstats:::.layoutConditionLabels(mk_group_name(silac), 2, 800, 4, 0, + TRUE, FALSE)$use_facets) + +# Test 32: on the plotly geometry it does not fit, and must switch to facets +expect_true(MSstats:::.layoutConditionLabels(mk_group_name(silac), 2, 800, 4, 0, + TRUE, TRUE)$use_facets) + +# Test 33: the plotly path is never less aggressive than the pdf path +expect_true(all(vapply(c(400, 800, 1200), function(w) { + pdf_facets = MSstats:::.layoutConditionLabels(mk_group_name(silac), 2, w, 4, 0, + TRUE, FALSE)$use_facets + plotly_facets = MSstats:::.layoutConditionLabels(mk_group_name(silac), 2, w, 4, 0, + TRUE, TRUE)$use_facets + plotly_facets >= pdf_facets +}, logical(1)))) + +# Test 34: a protein absent from some condition/label cells must still render. +# facet_grid(space = "free_x") computes a non-finite panel width for an empty +# panel on a discrete x scale, which fails in grid with "non-finite location +# and/or size for viewport" -- it took a real QC plot in MSstatsShiny to surface. +tp_names = c("0hr", "1hr", "4hr", "12hrs", "24hrs", "48hrs", "96hrs", "168hrs") +sparse_raw = SRMRawData[as.integer(as.character(SRMRawData$Condition)) <= 8, ] +sparse_raw$Condition = tp_names[as.integer(as.character(sparse_raw$Condition))] +first_protein = as.character(unique(sparse_raw$ProteinName))[1] +sparse_raw = sparse_raw[!(as.character(sparse_raw$ProteinName) == first_protein & + sparse_raw$Condition %in% c("96hrs", "168hrs")), ] +sparse_quant = dataProcess(sparse_raw, use_log_file = FALSE) +grDevices::pdf(NULL) +expect_silent_plot = function(type) { + res = try(suppressWarnings(dataProcessPlots( + sparse_quant, type = type, which.Protein = first_protein, + address = FALSE, isPlotly = TRUE)), silent = TRUE) + !inherits(res, "try-error") +} +expect_true(expect_silent_plot("QCPlot")) + +# Test 35: and the same on the profile plot +expect_true(expect_silent_plot("ProfilePlot")) +grDevices::dev.off() diff --git a/man/dot-layoutConditionLabels.Rd b/man/dot-layoutConditionLabels.Rd index 9d58b30f..3c5c4231 100644 --- a/man/dot-layoutConditionLabels.Rd +++ b/man/dot-layoutConditionLabels.Rd @@ -10,7 +10,8 @@ width, text.size, text.angle, - condition.label.adjust = TRUE + condition.label.adjust = TRUE, + isPlotly = FALSE ) } \arguments{ @@ -30,6 +31,13 @@ given half the room they really have.} \item{text.angle}{angle of the condition labels} \item{condition.label.adjust}{if FALSE, keep the in-panel layout unchanged} + +\item{isPlotly}{TRUE when the plot is bound for `ggplotly()`. The canvas is +the same number, but not the same size: on the pdf device `width` is points +at 72dpi, while plotly treats it as CSS pixels at 96dpi, which is a quarter +less room. Measuring both at 72dpi under-triggers the facet layout in the +browser -- an 8-condition SILAC design scores 0.80 on the pdf geometry and +1.11 on the real one, so it stayed in-panel and the labels collided.} } \value{ list with `use_facets`, and when that is TRUE the `wrap_chars` diff --git a/man/dot-makeProfilePlot.Rd b/man/dot-makeProfilePlot.Rd index 8c38af92..82ab335b 100644 --- a/man/dot-makeProfilePlot.Rd +++ b/man/dot-makeProfilePlot.Rd @@ -27,7 +27,8 @@ legend.ncol = NULL, max.legend.entries = 30, width = 800, - condition.label.adjust = TRUE + condition.label.adjust = TRUE, + isPlotly = FALSE ) } \arguments{ @@ -85,6 +86,17 @@ the horizontal space available to it, the condition is drawn as a facet strip and the name is wrapped to fit, rather than being printed inside the panel where it would overlap its neighbours. Plots whose condition labels already fit are left unchanged. FALSE reproduces the previous layout exactly.} + +\item{isPlotly}{Parameter to use Plotly or ggplot2. If set to TRUE, MSstats +will save Plotly plots as HTML files. If set to FALSE MSstats will save ggplot2 plots +as PDF files +Default folder is the current working directory. +The other assigned folder has to be existed under the current working directory. + An output pdf file is automatically created with the default name of + "ProfilePlot.pdf" or "QCplot.pdf" or "ConditionPlot.pdf" or "ConditionPlot_value.csv". + The command address can help to specify where to store the file as well as + how to modify the beginning of the file name. + If address=FALSE, plot will be not saved as pdf file but showed in window.} } \description{ Create profile plot diff --git a/man/dot-makeQCPlot.Rd b/man/dot-makeQCPlot.Rd index 7a1df1ad..1b792c41 100644 --- a/man/dot-makeQCPlot.Rd +++ b/man/dot-makeQCPlot.Rd @@ -20,7 +20,8 @@ lineNameAxis, yaxis.name, width = 800, - condition.label.adjust = TRUE + condition.label.adjust = TRUE, + isPlotly = FALSE ) } \arguments{ @@ -55,6 +56,17 @@ the horizontal space available to it, the condition is drawn as a facet strip and the name is wrapped to fit, rather than being printed inside the panel where it would overlap its neighbours. Plots whose condition labels already fit are left unchanged. FALSE reproduces the previous layout exactly.} + +\item{isPlotly}{Parameter to use Plotly or ggplot2. If set to TRUE, MSstats +will save Plotly plots as HTML files. If set to FALSE MSstats will save ggplot2 plots +as PDF files +Default folder is the current working directory. +The other assigned folder has to be existed under the current working directory. + An output pdf file is automatically created with the default name of + "ProfilePlot.pdf" or "QCplot.pdf" or "ConditionPlot.pdf" or "ConditionPlot_value.csv". + The command address can help to specify where to store the file as well as + how to modify the beginning of the file name. + If address=FALSE, plot will be not saved as pdf file but showed in window.} } \description{ To illustrate the quantitative data after data-preprocessing and diff --git a/man/dot-makeSummaryProfilePlot.Rd b/man/dot-makeSummaryProfilePlot.Rd index 2f503dc3..991e3303 100644 --- a/man/dot-makeSummaryProfilePlot.Rd +++ b/man/dot-makeSummaryProfilePlot.Rd @@ -21,7 +21,8 @@ groupNametemp, legend.position = "top", width = 800, - condition.label.adjust = TRUE + condition.label.adjust = TRUE, + isPlotly = FALSE ) } \arguments{ @@ -62,6 +63,17 @@ the horizontal space available to it, the condition is drawn as a facet strip and the name is wrapped to fit, rather than being printed inside the panel where it would overlap its neighbours. Plots whose condition labels already fit are left unchanged. FALSE reproduces the previous layout exactly.} + +\item{isPlotly}{Parameter to use Plotly or ggplot2. If set to TRUE, MSstats +will save Plotly plots as HTML files. If set to FALSE MSstats will save ggplot2 plots +as PDF files +Default folder is the current working directory. +The other assigned folder has to be existed under the current working directory. + An output pdf file is automatically created with the default name of + "ProfilePlot.pdf" or "QCplot.pdf" or "ConditionPlot.pdf" or "ConditionPlot_value.csv". + The command address can help to specify where to store the file as well as + how to modify the beginning of the file name. + If address=FALSE, plot will be not saved as pdf file but showed in window.} } \description{ Make summary profile plot