From de1b1a4aea1a78b206e8c831ec25f8e486dcdb1d Mon Sep 17 00:00:00 2001 From: Rudhik1904 Date: Sat, 5 Sep 2026 22:34:31 -0500 Subject: [PATCH 1/6] fix(dataProcessPlots): Stop condition labels and legend from covering the plot The Plotly output used by MSstatsShiny was unreadable on two axes. Condition names are drawn inside the panel at each block's midpoint, so long names or many conditions ran them together into a smear. The feature legend sat above the panel and grew with the feature count until it covered the axes, and ggplotly silently dropped entries past the first ten rather than scrolling. Legend: move it to the side, where Plotly scrolls an over-tall legend instead of growing it, so no protein can cover the plot and no entries are dropped. The theme has to be set before ggplotly() runs, not only overridden in layout() afterwards -- ggplotly reserves the legend band from the ggplot theme, and disagreeing with it leaves a dead strip across the top and squeezes the panel into the corner. .convertGgplot2Plotly() also stops hard-coding 800x600 and honours the caller's width and height, which were being ignored. New legend.position and width.plotly arguments expose the placement and the canvas; width.plotly defaults to the 1400px container MSstatsShiny already reserves for these plots. Condition labels: fit them to the room each condition actually gets, applying the mitigations in order of what they cost the reader. Drop the stem every name shares and report it once in the x-axis title, then shrink the font, then wrap. Each step is a no-op when the labels already fit, so plots that render correctly today are untouched. Rotation is deliberately not used: ggplotly does not carry it through, so it does nothing for the Shiny output. Truncation is the one lossy step, so .fixConditionLabelHoverPlotly() puts the untruncated name back on hover. That is done as trace surgery after conversion, matching the other .fix*Plotly helpers, because an aes(text=) mapping would emit "Ignoring unknown aesthetics" on every call. Detected/censored keys are pinned above the feature list with legendrank so they stay visible without scrolling, and stay clickable. Verified against the MSstatsShiny call path (address = FALSE, isPlotly = TRUE) for profile, profile-with-summary and QC plots, including a 60-feature protein and a worst case of ten long names sharing no prefix at one run per condition. Co-Authored-By: Claude Opus 5 (1M context) --- R/dataProcessPlots.R | 143 +++++++++++++++++----- R/utils_dataprocess_plots.R | 228 ++++++++++++++++++++++++++++++++++-- 2 files changed, 332 insertions(+), 39 deletions(-) diff --git a/R/dataProcessPlots.R b/R/dataProcessPlots.R index 76da2a7e..c580cde8 100644 --- a/R/dataProcessPlots.R +++ b/R/dataProcessPlots.R @@ -73,6 +73,14 @@ #' 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 the Plotly output of +#' Profile Plot and QC Plot: "right" (default), "left", "top", "bottom", or "none" +#' to hide it. A side-mounted legend is scrollable in Plotly, so a protein with many +#' features can no longer cover the plot. Only affects \code{isPlotly = TRUE}; the +#' ggplot2 (PDF) output keeps its legend above the graph. +#' @param width.plotly width in pixels of the Plotly output. Default is 1400, which +#' matches the container MSstatsShiny reserves for these plots. Only affects +#' \code{isPlotly = TRUE}; the PDF output is sized by \code{width}. #' #' @details #' \itemize{ @@ -114,7 +122,8 @@ 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, + legend.position = "right", width.plotly = 1400 ) { PROTEIN = Protein = NULL @@ -144,7 +153,8 @@ 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, + width.plotly) plotly_plots = list() if(isPlotly) { og_plotly_plot = NULL @@ -152,10 +162,13 @@ 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, + width = width.plotly, height = height) og_plotly_plot = .fixLegendPlotlyPlotsDataprocess(og_plotly_plot) og_plotly_plot = .fixCensoredPointsLegendProfilePlotsPlotly(og_plotly_plot) og_plotly_plot = .fixErrorBarCapsPlotly(og_plotly_plot) + og_plotly_plot = .fixConditionLabelHoverPlotly(og_plotly_plot, plot_i) if(toupper(featureName) == "NA") { og_plotly_plot = .retainCensoredDataPoints(og_plotly_plot) @@ -166,10 +179,13 @@ 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, + width = width.plotly, height = height) summ_plotly_plot = .fixLegendPlotlyPlotsDataprocess(summ_plotly_plot) summ_plotly_plot = .fixCensoredPointsLegendProfilePlotsPlotly(summ_plotly_plot) summ_plotly_plot = .fixErrorBarCapsPlotly(summ_plotly_plot) + summ_plotly_plot = .fixConditionLabelHoverPlotly(summ_plotly_plot, plot_i) if(toupper(featureName) == "NA") { summ_plotly_plot = .retainCensoredDataPoints(summ_plotly_plot) } @@ -187,13 +203,15 @@ 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, width.plotly) plotly_plots <- vector("list", length(plots)) if(isPlotly) { for(i in seq_along(plots)) { plot <- plots[[i]] - plotly_plot <- .convertGgplot2Plotly(plot) + plotly_plot <- .convertGgplot2Plotly(plot, legend_position = legend.position, + width = width.plotly, height = height) plotly_plot = .fixLegendPlotlyPlotsDataprocess(plotly_plot) + plotly_plot = .fixConditionLabelHoverPlotly(plotly_plot, plot) plotly_plots[[i]] = list(plotly_plot) } if(address != FALSE) { @@ -213,7 +231,8 @@ dataProcessPlots = function( if(isPlotly) { for(i in seq_along(plots)) { plot <- plots[[i]] - plotly_plot <- .convertGgplot2Plotly(plot) + plotly_plot <- .convertGgplot2Plotly(plot, legend_position = legend.position, + width = width.plotly, height = height) plotly_plot = .fixLegendPlotlyPlotsDataprocess(plotly_plot) plotly_plots[[i]] = list(plotly_plot) } @@ -233,7 +252,8 @@ 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, + width.plotly = 1400 ) { ABUNDANCE = PROTEIN = feature_quality = is_outlier = Protein = GROUP = NULL SUBJECT = LABEL = RUN = xtabs = PEPTIDE = FEATURE = NULL @@ -283,9 +303,20 @@ dataProcessPlots = function( y.limup = ifelse(is.numeric(ylimUp), ylimUp, ceiling(max(processed$ABUNDANCE, na.rm = TRUE) + 3)) y.limdown = ifelse(is.numeric(ylimDown), ylimDown, -1) + # Fit the condition names to the room each condition actually gets. Labels + # that already fit come back untouched; the rest are shortened, shrunk and + # wrapped, and any extra lines are paid for with headroom above the data. + n_facets = data.table::uniqueN(processed$LABEL) + condition.layout = .layoutConditionLabels( + levels(tempGroupName$GROUP), n_facets, + if (isPlotly) width.plotly else width, text.size, text.angle) + if (!is.numeric(ylimUp)) { + y.limup = y.limup + (condition.layout$n_lines - 1) * 0.9 + } groupName = data.frame(RUN = c(0, lineNameAxis) + groupAxis / 2 + 0.5, - ABUNDANCE = rep(y.limup - 1, length(groupAxis)), - Name = levels(tempGroupName$GROUP)) + ABUNDANCE = rep(y.limup - 0.5, length(groupAxis)), + Name = levels(tempGroupName$GROUP), + Label = condition.layout$labels) if ("is_labeled_ref" %in% colnames(processed)) { @@ -349,7 +380,8 @@ dataProcessPlots = function( text.size, text.angle, legend.size, dot.size.profile, ss, s, cumGroupAxis, yaxis.name, - lineNameAxis, groupNametemp, dot_colors) + lineNameAxis, groupNametemp, dot_colors, + condition.layout) setTxtProgressBar(pb, i) print(profile_plot) @@ -413,7 +445,7 @@ dataProcessPlots = function( 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, condition.layout ) print(profile_plot) setTxtProgressBar(pb, i) @@ -437,7 +469,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, + width.plotly = 1400 ) { GROUP = SUBJECT = RUN = LABEL = PROTEIN = NULL @@ -479,9 +512,17 @@ dataProcessPlots = function( groupAxis = as.numeric(xtabs(~GROUP, tempGroupName)) cumGroupAxis = cumsum(groupAxis) lineNameAxis = cumGroupAxis[-nlevels(tempGroupName$GROUP)] + n_facets = data.table::uniqueN(processed$LABEL) + condition.layout = .layoutConditionLabels( + levels(tempGroupName$GROUP), n_facets, + if (isPlotly) width.plotly else width, text.size, text.angle) + if (!is.numeric(ylimUp)) { + y.limup = y.limup + (condition.layout$n_lines - 1) * 0.9 + } groupName = data.frame(RUN = c(0, lineNameAxis) + groupAxis / 2 + 0.5, - ABUNDANCE = rep(y.limup - 1, length(groupAxis)), - Name = levels(tempGroupName$GROUP)) + ABUNDANCE = rep(y.limup - 0.5, length(groupAxis)), + Name = levels(tempGroupName$GROUP), + Label = condition.layout$labels) if (!isPlotly) { savePlot(address, "QCPlot", width, height) } @@ -491,7 +532,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, condition.layout) print(qc_plot) plots[[1]] = qc_plot } @@ -514,7 +555,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, condition.layout) print(qc_plot) plots[[i+1]] = qc_plot # to accomodate all proteins setTxtProgressBar(pb, i) @@ -615,14 +656,57 @@ dataProcessPlots = function( } } +#' restore the untruncated condition name in the Plotly hover +#' +#' `.layoutConditionLabels()` may have shortened what is drawn in the panel. The +#' condition labels arrive as a single text-mode trace, so the full names can be +#' put back on hover without disturbing the drawn text. +#' @param plot converted plotly plot +#' @param ggplot_obj the ggplot it was converted from, carrying the drawn +#' `Label` and the untruncated `Name` on its condition label layer +#' @noRd +.fixConditionLabelHoverPlotly = function(plot, ggplot_obj) { + full_names = NULL + for (layer in ggplot_obj$layers) { + if (all(c("Name", "Label") %in% colnames(layer$data))) { + full_names = as.character(layer$data$Name) + break + } + } + if (is.null(full_names)) { + return(plot) + } + for (i in seq_along(plot$x$data)) { + trace = plot$x$data[[i]] + if (identical(trace$mode, "text") && + length(trace$text) == length(full_names)) { + plot$x$data[[i]]$hovertext = full_names + } + } + plot +} + #' converter for plots from ggplot to plotly +#' +#' `ggplotly()` reserves the legend band from the ggplot theme, so the theme has +#' to agree with the `layout()` override below. Leaving the theme at "top" while +#' moving the legend to the side leaves a dead band across the top and squeezes +#' the panel into the lower-left corner. +#' +#' The legend is mounted on the side rather than below the panel because plotly +#' scrolls an over-tall vertical legend instead of growing it. A protein with +#' hundreds of features can then no longer cover the plot, and no entries are +#' dropped. #' @noRd -.convertGgplot2Plotly = function(plot, tips = "all") { - converted_plot <- ggplotly(plot,tooltip = tips) +.convertGgplot2Plotly = function(plot, tips = "all", legend_position = "right", + width = 1400, height = 600) { + plot = plot + theme(legend.position = legend_position) + converted_plot <- ggplotly(plot, tooltip = tips, width = width, + height = height) 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 + # Room for the title, which the top-mounted legend used to sit on. + margin = list(t = 60), title = list( font = list( size = 18 @@ -634,11 +718,12 @@ dataProcessPlots = function( ) ), 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 + x = 1.02, # Just outside the panel on the right + y = 1, + xanchor = "left", + orientation = "v", # Vertical, so plotly makes it scrollable font = list( - size = 12 # Set the font size for legend item labels + size = 10 # Set the font size for legend item labels ), title = list( font = list( @@ -684,16 +769,20 @@ dataProcessPlots = function( first_false_index <- which(df$legend_entries == "FALSE")[1] first_true_index <- which(df$legend_entries == "TRUE")[1] - # Update plot data for the first occurrence of "FALSE" + # Pin the two shape entries above the feature list. The feature legend + # scrolls once a protein has more features than fit, and these two are the + # key to reading the plot -- left at the default rank they end up below the + # fold. Lower legendrank sorts first; plotly's default is 1000. if (!is.na(first_false_index)) { plot$x$data[[first_false_index]]$name <- "Detected data" plot$x$data[[first_false_index]]$showlegend <- TRUE + plot$x$data[[first_false_index]]$legendrank <- 1 } - # Update plot data for the first occurrence of "TRUE" if (!is.na(first_true_index)) { plot$x$data[[first_true_index]]$name <- "Censored missing data" plot$x$data[[first_true_index]]$showlegend <- TRUE + plot$x$data[[first_true_index]]$legendrank <- 2 } plot } diff --git a/R/utils_dataprocess_plots.R b/R/utils_dataprocess_plots.R index 5036773a..59a20a42 100644 --- a/R/utils_dataprocess_plots.R +++ b/R/utils_dataprocess_plots.R @@ -33,6 +33,206 @@ } +#' Drop the prefix that every condition name shares +#' +#' Condition names in real designs are usually built from a common stem plus a +#' distinguishing tail -- "Cyno_Colon_Timepoint_0hr", "Cyno_Colon_Timepoint_12hrs". +#' Only the tail identifies the block, but the shared stem is what consumes the +#' horizontal room, so it is dropped from the in-panel label and reported once in +#' the x-axis title instead. No information is lost from the static image. +#' +#' @param names character, condition names in plotting order +#' @return list with `labels` (shortened) and `prefix` (what was removed, "" when +#' nothing is shared) +#' @keywords internal +.stripCommonAffix = function(names) { + names = as.character(names) + unchanged = list(labels = names, prefix = "") + if (length(unique(names)) < 2L) { + return(unchanged) + } + # Split after each separator so the separator stays with the token it follows + # and the pieces can simply be pasted back together. + tokens = strsplit(names, "(?<=[_.[:space:]-])", perl = TRUE) + n_shared = 0L + repeat { + # Never consume a name entirely; a condition with no label left would be + # indistinguishable from its neighbours. + nth = vapply(tokens, function(x) { + if (length(x) > n_shared + 1L) x[n_shared + 1L] else NA_character_ + }, character(1)) + if (anyNA(nth) || length(unique(nth)) != 1L) { + break + } + n_shared = n_shared + 1L + } + if (n_shared == 0L) { + return(unchanged) + } + list(labels = vapply(tokens, function(x) { + paste(x[-seq_len(n_shared)], collapse = "") + }, character(1)), + prefix = paste(tokens[[1]][seq_len(n_shared)], collapse = "")) +} + + +#' Number of characters that fit in one condition's slot +#' +#' Conditions tile the panel evenly, so each name gets `panel_width / +#' n_conditions` of room no matter how many runs it covers -- which is why +#' crowding is a function of name length and condition count, and never of the +#' number of samples per condition. +#' +#' Width is estimated from `nchar` rather than 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 layout device-dependent and this +#' function untestable. 0.53 em per character is calibrated against +#' `graphics::strwidth()` and lands within ~7%. +#' +#' @param n_conditions number of conditions +#' @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. +#' @param width width of the canvas in pixels, read as CSS pixels at 96dpi +#' @param text.size size of the condition labels +#' @return integer, at least 1 +#' @keywords internal +.conditionSlotChars = function(n_conditions, n_facets, width, text.size) { + if (!is.numeric(width) || width <= 0 || n_conditions < 1L) { + return(.Machine$integer.max) + } + # ~1.1in of the canvas goes to the y-axis title, tick labels and margins; + # what is left is split across the facets and then across the conditions. + panel_in = (width / 96 - 1.1) / max(n_facets, 1L) + # Only fill part of the slot: a label that fills it exactly touches its + # neighbours, and the first and last labels overhang the panel edge because + # they are centred on their block. + slot_in = 0.85 * panel_in / n_conditions + char_in = text.size * ggplot2::.pt * 0.53 / 72 + if (slot_in <= 0 || char_in <= 0) { + return(1L) + } + max(1L, as.integer(floor(slot_in / char_in))) +} + + +#' Wrap condition names onto several lines so they fit their slot +#' +#' Used only for what `.stripCommonAffix()` and shrinking the font cannot fix. +#' `strwrap()` breaks only at whitespace and condition names are usually +#' underscore-delimited, so separators are turned into break opportunities here. +#' A single token wider than the slot cannot be broken and is truncated; the +#' untruncated name stays available in the Plotly hover. +#' +#' @param names character, condition names +#' @param chars maximum characters per line +#' @return character, `names` unchanged when they all already fit +#' @keywords internal +.wrapConditionLabels = function(names, chars) { + names = as.character(names) + if (all(nchar(names) <= chars)) { + return(names) + } + vapply(names, function(name) { + tokens = regmatches(name, gregexpr("[^_.[:space:]-]+[_.[:space:]-]*", + name))[[1]] + if (length(tokens) == 0L) { + tokens = name + } + tokens = vapply(tokens, function(token) { + if (nchar(token) > chars) { + paste0(substr(token, 1L, max(1L, chars - 3L)), "...") + } else { + token + } + }, character(1), USE.NAMES = FALSE) + lines = character(0) + current = "" + for (token in tokens) { + candidate = paste0(current, token) + if (nchar(trimws(candidate)) > chars && nzchar(current)) { + lines = c(lines, current) + current = token + } else { + current = candidate + } + } + paste(c(lines, current), collapse = "\n") + }, character(1), USE.NAMES = FALSE) +} + + +#' Lay out condition labels so they do not overlap +#' +#' Applies the three mitigations in order of how much they cost the reader: +#' drop the shared stem, then shrink the font, then wrap. Each is a no-op when +#' the labels already fit, so a plot that renders correctly today is unchanged. +#' +#' @inheritParams .conditionSlotChars +#' @param names character, condition names in plotting order +#' @param text.angle angle of the labels. A non-zero value is a deliberate choice +#' by the caller, so the layout is left alone. Note that rotation is not carried +#' through by `ggplotly()`, so it does not help the MSstatsShiny output. +#' @return list with `labels`, the `size` to draw them at, the `n_lines` they +#' occupy, and the `xaxis` title to use +#' @keywords internal +.layoutConditionLabels = function(names, n_facets, width, text.size, + text.angle = 0) { + labels = as.character(names) + unchanged = list(labels = labels, size = text.size, n_lines = 1L, + xaxis = "MS runs") + if (!isTRUE(all.equal(as.numeric(text.angle), 0))) { + return(unchanged) + } + n_conditions = length(labels) + if (n_conditions < 2L) { + return(unchanged) + } + if (max(nchar(labels)) <= + .conditionSlotChars(n_conditions, n_facets, width, text.size)) { + return(unchanged) + } + xaxis = "MS runs" + stripped = .stripCommonAffix(labels) + if (nzchar(stripped$prefix)) { + labels = stripped$labels + xaxis = paste0("MS runs (conditions: ", stripped$prefix, "*)") + } + # Shrink before wrapping: one legible line beats two cramped ones. The floor + # is where shrinking stops buying fit and starts buying illegibility. + size = text.size + repeat { + chars = .conditionSlotChars(n_conditions, n_facets, width, size) + if (max(nchar(labels)) <= chars || size <= 2.5) { + break + } + size = size - 0.25 + } + labels = .wrapConditionLabels(labels, chars) + list(labels = labels, size = size, + n_lines = max(lengths(strsplit(labels, "\n", fixed = TRUE))), + xaxis = xaxis) +} + + +#' Accessors for the condition label layout +#' +#' The builders are also called with `condition.layout = NULL` (nothing computed +#' a layout), in which case they fall back to the historical behaviour. +#' @param layout result of `.layoutConditionLabels()`, or NULL +#' @keywords internal +.conditionXlab = function(layout) { + if (is.null(layout$xaxis)) "MS runs" else layout$xaxis +} + +#' @rdname dot-conditionXlab +#' @param text.size size to fall back to +#' @keywords internal +.conditionTextSize = function(layout, text.size) { + if (is.null(layout$size)) text.size else layout$size +} + #' Create profile plot #' @inheritParams dataProcessPlots #' @param input data.table @@ -41,7 +241,8 @@ .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, + condition.layout = NULL ) { RUN = ABUNDANCE = Name = NULL @@ -93,13 +294,14 @@ profile_plot = profile_plot + scale_linetype_manual(values = ss, guide = "none") profile_plot = profile_plot + - scale_x_continuous('MS runs', breaks = cumGroupAxis) + + scale_x_continuous(.conditionXlab(condition.layout), 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, + geom_text(data = groupNametemp, aes(x = .data$RUN, y = .data$ABUNDANCE, label = .data$Label), + size = .conditionTextSize(condition.layout, text.size), angle = text.angle, + vjust = 1, color = "black") + theme_msstats("PROFILEPLOT", x.axis.size, y.axis.size, legend.size) @@ -150,7 +352,7 @@ .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, condition.layout = NULL ) { RUN = ABUNDANCE = Name = NULL @@ -194,14 +396,15 @@ scale_size_manual(values = c(1.7, 2), guide = "none") + scale_linetype_manual(values = c(rep(1, times = num_features - 1), 2), guide = "none") + - scale_x_continuous("MS runs", breaks = cumGroupAxis) + + scale_x_continuous(.conditionXlab(condition.layout), 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, + geom_text(data = groupNametemp, aes(x = .data$RUN, y = .data$ABUNDANCE, label = .data$Label), + size = .conditionTextSize(condition.layout, text.size), angle = text.angle, + vjust = 1, color = "black") + theme_msstats("PROFILEPLOT", x.axis.size, y.axis.size, legend.size, legend.title = element_blank()) @@ -232,7 +435,7 @@ .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, condition.layout = NULL ) { RUN = ABUNDANCE = Name = NULL @@ -247,13 +450,14 @@ 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_x_discrete(.conditionXlab(condition.layout), 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 = plot_title) + - geom_text(data = groupName, aes(x = .data$RUN, y = .data$ABUNDANCE, label = .data$Name), - size = text.size, angle = text.angle, color = "black") + + geom_text(data = groupName, aes(x = .data$RUN, y = .data$ABUNDANCE, label = .data$Label), + size = .conditionTextSize(condition.layout, text.size), + angle = text.angle, vjust = 1, color = "black") + theme_msstats("QCPLOT", x.axis.size, y.axis.size, legend_size = NULL) From eb9084fdd666373e6eb29cff261108f412522d4f Mon Sep 17 00:00:00 2001 From: Rudhik1904 Date: Sat, 5 Sep 2026 22:46:08 -0500 Subject: [PATCH 2/6] docs(dataProcessPlots): Add tests, regenerate Rd and note the plot fixes Covers the condition label layout helpers directly. They are pure functions of the names and the canvas geometry, so their edge cases -- a stem shared only mid-token, names that would strip to nothing, a token too wide to break, the legibility floor on the font -- are worth pinning down without rendering a plot to look at them. devtools::check() leaves 2 errors, both of which reproduce unchanged on devel: the dataProcess FeatureLevelData snapshot in test_dataProcess.R, and MSstatsWorkflow.Rmd sourcing R files by relative path. Neither involves the plotting code touched here. Co-Authored-By: Claude Opus 5 (1M context) --- inst/NEWS.rd | 8 ++ inst/tinytest/test_utils_dataprocess_plots.R | 137 +++++++++++++++++++ man/dataProcessPlots.Rd | 14 +- man/dot-conditionSlotChars.Rd | 37 +++++ man/dot-conditionXlab.Rd | 21 +++ man/dot-layoutConditionLabels.Rd | 34 +++++ man/dot-makeProfilePlot.Rd | 3 +- man/dot-makeQCPlot.Rd | 3 +- man/dot-makeSummaryProfilePlot.Rd | 3 +- man/dot-stripCommonAffix.Rd | 23 ++++ man/dot-wrapConditionLabels.Rd | 24 ++++ 11 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 inst/tinytest/test_utils_dataprocess_plots.R create mode 100644 man/dot-conditionSlotChars.Rd create mode 100644 man/dot-conditionXlab.Rd create mode 100644 man/dot-layoutConditionLabels.Rd create mode 100644 man/dot-stripCommonAffix.Rd create mode 100644 man/dot-wrapConditionLabels.Rd diff --git a/inst/NEWS.rd b/inst/NEWS.rd index a7422072..84302f6e 100644 --- a/inst/NEWS.rd +++ b/inst/NEWS.rd @@ -2,6 +2,14 @@ \title{News for package, \pkg{MSstats}} \encoding{UTF-8} +\section{Version 4.22.0 (in development)}{ + \itemize{ + \item \strong{Profile and QC plots}: Condition names no longer overlap each other. When a name is wider than the horizontal room its condition is given, the stem shared by every condition is moved to the x-axis title, the label font is reduced, and the remainder is wrapped. Plots whose condition labels already fit are unchanged. In the Plotly output the untruncated name is available on hover. + \item \strong{Profile and QC plots}: In the Plotly output the feature legend is now mounted beside the plot rather than above it, where Plotly makes an over-tall legend scrollable. Proteins with many features no longer have the legend cover the plot, and legend entries are no longer silently dropped. The new \code{legend.position} argument of \code{dataProcessPlots} repositions or hides it, and \code{width.plotly} sets the width of the Plotly canvas. + \item \strong{Bug fix}: \code{dataProcessPlots} ignored \code{width} and \code{height} when \code{isPlotly = TRUE}, always producing an 800x600 plot. + } +} + \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..9ed29039 --- /dev/null +++ b/inst/tinytest/test_utils_dataprocess_plots.R @@ -0,0 +1,137 @@ +# Condition label layout helpers. +# +# These decide what is drawn in place of a condition name that does not fit the +# horizontal room it is given. They are pure functions of the names and the +# canvas geometry, so they are tested directly rather than through a rendered +# plot. + +strip = MSstats:::.stripCommonAffix +slot_chars = MSstats:::.conditionSlotChars +wrap = MSstats:::.wrapConditionLabels +layout_labels = MSstats:::.layoutConditionLabels + +# Test .stripCommonAffix ---------------------------------------------------- + +# Test 1: the shared stem is removed and reported +result = strip(c("Cyno_Colon_Timepoint_0hr", "Cyno_Colon_Timepoint_12hrs")) +expect_equal(result$labels, c("0hr", "12hrs")) +expect_equal(result$prefix, "Cyno_Colon_Timepoint_") + +# Test 2: names sharing nothing are left alone +result = strip(c("Alpha", "Beta")) +expect_equal(result$labels, c("Alpha", "Beta")) +expect_equal(result$prefix, "") + +# Test 3: a shared stem that is not on a separator boundary is not split. +# "Control" and "Contrast" share "Cont", but chopping mid-token would leave +# labels that do not correspond to anything in the data. +result = strip(c("Control_1", "Contrast_1")) +expect_equal(result$prefix, "") + +# Test 4: identical names are left alone rather than reduced to nothing +result = strip(c("same", "same")) +expect_equal(result$labels, c("same", "same")) +expect_equal(result$prefix, "") + +# Test 5: a name is never consumed entirely. Every name here starts with the +# whole of the first, so stripping greedily would leave an empty label. +result = strip(c("A_B", "A_B_C")) +expect_true(all(nzchar(result$labels))) + +# Test 6: a single condition has no shared stem to speak of +result = strip("OnlyOne") +expect_equal(result$labels, "OnlyOne") +expect_equal(result$prefix, "") + +# Test 7: separators other than underscore are honoured +expect_equal(strip(c("run.a", "run.b"))$prefix, "run.") +expect_equal(strip(c("run a", "run b"))$prefix, "run ") +expect_equal(strip(c("run-a", "run-b"))$prefix, "run-") + +# Test .conditionSlotChars -------------------------------------------------- + +# Test 8: more conditions in the same canvas means fewer characters each +expect_true(slot_chars(20, 1, 1400, 4) < slot_chars(5, 1, 1400, 4)) + +# Test 9: a wider canvas means more characters +expect_true(slot_chars(10, 1, 1400, 4) > slot_chars(10, 1, 800, 4)) + +# Test 10: splitting the canvas across facets means fewer characters +expect_true(slot_chars(10, 2, 1400, 4) < slot_chars(10, 1, 1400, 4)) + +# Test 11: a larger font means fewer characters +expect_true(slot_chars(10, 1, 1400, 8) < slot_chars(10, 1, 1400, 4)) + +# Test 12: never returns less than one character, however cramped +expect_true(slot_chars(500, 4, 200, 12) >= 1L) + +# Test 13: a canvas of unknown width imposes no limit, so nothing is shortened +expect_equal(slot_chars(10, 1, NA, 4), .Machine$integer.max) +expect_equal(slot_chars(10, 1, 0, 4), .Machine$integer.max) + +# Test .wrapConditionLabels ------------------------------------------------- + +# Test 14: names that already fit are returned untouched +expect_equal(wrap(c("0hr", "12hrs"), 10), c("0hr", "12hrs")) + +# Test 15: wrapping happens at separators, not mid-token +expect_equal(wrap("aaaa_bbbb_cccc", 6), "aaaa_\nbbbb_\ncccc") + +# Test 16: a single token wider than the slot cannot be broken, so it is +# truncated to exactly the slot width +result = wrap("ABCDEFGHIJKLMNOP", 6) +expect_equal(result, "ABC...") +expect_equal(nchar(result), 6L) + +# Test 17: every wrapped line respects the limit +lines = unlist(strsplit(wrap("alpha_beta_gamma_delta", 8), "\n", fixed = TRUE)) +expect_true(all(nchar(lines) <= 8L)) + +# Test .layoutConditionLabels ----------------------------------------------- + +short = c("1", "2", "3") +long = paste0("Cyno_Colon_Timepoint_", c("0hr", "12hrs", "168hrs")) + +# Test 18: labels that already fit are returned unchanged, at the caller's font +# size, on one line, under the plain axis title. This is the path every dataset +# that renders correctly today takes. +result = layout_labels(short, 1, 1400, 4) +expect_equal(result$labels, short) +expect_equal(result$size, 4) +expect_equal(result$n_lines, 1L) +expect_equal(result$xaxis, "MS runs") + +# Test 19: labels that do not fit are shortened, and the stem moves to the axis +# title so it is still reported +result = layout_labels(long, 2, 800, 4) +expect_equal(result$labels, c("0hr", "12hrs", "168hrs")) +expect_true(grepl("Cyno_Colon_Timepoint_", result$xaxis, fixed = TRUE)) + +# Test 20: a non-zero text.angle is a deliberate choice by the caller, so the +# layout is left alone. Note that ggplotly() does not carry rotation through, +# which is why rotation is not used as a mitigation here. +result = layout_labels(long, 2, 800, 4, text.angle = 90) +expect_equal(result$labels, long) +expect_equal(result$xaxis, "MS runs") + +# Test 21: a single condition cannot collide with anything +result = layout_labels("OnlyOneVeryLongConditionName", 1, 400, 4) +expect_equal(result$labels, "OnlyOneVeryLongConditionName") + +# Test 22: when stripping cannot help, the font shrinks rather than giving up, +# but not below the legibility floor +no_stem = c("AlphaHepatocyteBaseline", "BetaRenalCortexStimulated", + "GammaCardiacTissue") +result = layout_labels(no_stem, 2, 800, 4) +expect_true(result$size < 4) +expect_true(result$size >= 2.5) + +# Test 23: the drawn labels stay distinguishable from one another even in that +# worst case, which is the whole point of the exercise +expect_equal(length(unique(result$labels)), length(no_stem)) + +# Test 24: n_lines reports the tallest label, so the caller knows how much +# headroom to add above the data +result = layout_labels(c("alpha_beta_gamma", "delta_epsilon_zeta"), 1, 300, 4) +expect_equal(result$n_lines, + max(lengths(strsplit(result$labels, "\n", fixed = TRUE)))) diff --git a/man/dataProcessPlots.Rd b/man/dataProcessPlots.Rd index 2d1f08ca..9a5a877c 100644 --- a/man/dataProcessPlots.Rd +++ b/man/dataProcessPlots.Rd @@ -27,7 +27,9 @@ dataProcessPlots( save_condition_plot_result = FALSE, remove_uninformative_feature_outlier = FALSE, address = "", - isPlotly = FALSE + isPlotly = FALSE, + legend.position = "right", + width.plotly = 1400 ) } \arguments{ @@ -115,6 +117,16 @@ 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 the Plotly output of +Profile Plot and QC Plot: "right" (default), "left", "top", "bottom", or "none" +to hide it. A side-mounted legend is scrollable in Plotly, so a protein with many +features can no longer cover the plot. Only affects \code{isPlotly = TRUE}; the +ggplot2 (PDF) output keeps its legend above the graph.} + +\item{width.plotly}{width in pixels of the Plotly output. Default is 1400, which +matches the container MSstatsShiny reserves for these plots. Only affects +\code{isPlotly = TRUE}; the PDF output is sized by \code{width}.} } \description{ To illustrate the quantitative data after data-preprocessing and diff --git a/man/dot-conditionSlotChars.Rd b/man/dot-conditionSlotChars.Rd new file mode 100644 index 00000000..b58bfecf --- /dev/null +++ b/man/dot-conditionSlotChars.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_dataprocess_plots.R +\name{.conditionSlotChars} +\alias{.conditionSlotChars} +\title{Number of characters that fit in one condition's slot} +\usage{ +.conditionSlotChars(n_conditions, n_facets, width, text.size) +} +\arguments{ +\item{n_conditions}{number of conditions} + +\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.} + +\item{width}{width of the canvas in pixels, read as CSS pixels at 96dpi} + +\item{text.size}{size of the condition labels} +} +\value{ +integer, at least 1 +} +\description{ +Conditions tile the panel evenly, so each name gets `panel_width / +n_conditions` of room no matter how many runs it covers -- which is why +crowding is a function of name length and condition count, and never of the +number of samples per condition. +} +\details{ +Width is estimated from `nchar` rather than 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 layout device-dependent and this +function untestable. 0.53 em per character is calibrated against +`graphics::strwidth()` and lands within ~7%. +} +\keyword{internal} diff --git a/man/dot-conditionXlab.Rd b/man/dot-conditionXlab.Rd new file mode 100644 index 00000000..c1e77dd7 --- /dev/null +++ b/man/dot-conditionXlab.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_dataprocess_plots.R +\name{.conditionXlab} +\alias{.conditionXlab} +\alias{.conditionTextSize} +\title{Accessors for the condition label layout} +\usage{ +.conditionXlab(layout) + +.conditionTextSize(layout, text.size) +} +\arguments{ +\item{layout}{result of `.layoutConditionLabels()`, or NULL} + +\item{text.size}{size to fall back to} +} +\description{ +The builders are also called with `condition.layout = NULL` (nothing computed +a layout), in which case they fall back to the historical behaviour. +} +\keyword{internal} diff --git a/man/dot-layoutConditionLabels.Rd b/man/dot-layoutConditionLabels.Rd new file mode 100644 index 00000000..047764f7 --- /dev/null +++ b/man/dot-layoutConditionLabels.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_dataprocess_plots.R +\name{.layoutConditionLabels} +\alias{.layoutConditionLabels} +\title{Lay out condition labels so they do not overlap} +\usage{ +.layoutConditionLabels(names, n_facets, width, text.size, text.angle = 0) +} +\arguments{ +\item{names}{character, condition names in plotting order} + +\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.} + +\item{width}{width of the canvas in pixels, read as CSS pixels at 96dpi} + +\item{text.size}{size of the condition labels} + +\item{text.angle}{angle of the labels. A non-zero value is a deliberate choice +by the caller, so the layout is left alone. Note that rotation is not carried +through by `ggplotly()`, so it does not help the MSstatsShiny output.} +} +\value{ +list with `labels`, the `size` to draw them at, the `n_lines` they + occupy, and the `xaxis` title to use +} +\description{ +Applies the three mitigations in order of how much they cost the reader: +drop the shared stem, then shrink the font, then wrap. Each is a no-op when +the labels already fit, so a plot that renders correctly today is unchanged. +} +\keyword{internal} diff --git a/man/dot-makeProfilePlot.Rd b/man/dot-makeProfilePlot.Rd index c259fa28..edd4f21b 100644 --- a/man/dot-makeProfilePlot.Rd +++ b/man/dot-makeProfilePlot.Rd @@ -22,7 +22,8 @@ yaxis.name, lineNameAxis, groupNametemp, - dot_colors + dot_colors, + condition.layout = NULL ) } \arguments{ diff --git a/man/dot-makeQCPlot.Rd b/man/dot-makeQCPlot.Rd index 98b17d6c..b3344c23 100644 --- a/man/dot-makeQCPlot.Rd +++ b/man/dot-makeQCPlot.Rd @@ -18,7 +18,8 @@ cumGroupAxis, groupName, lineNameAxis, - yaxis.name + yaxis.name, + condition.layout = NULL ) } \arguments{ diff --git a/man/dot-makeSummaryProfilePlot.Rd b/man/dot-makeSummaryProfilePlot.Rd index 94a2f935..72c08af3 100644 --- a/man/dot-makeSummaryProfilePlot.Rd +++ b/man/dot-makeSummaryProfilePlot.Rd @@ -18,7 +18,8 @@ cumGroupAxis, yaxis.name, lineNameAxis, - groupNametemp + groupNametemp, + condition.layout = NULL ) } \arguments{ diff --git a/man/dot-stripCommonAffix.Rd b/man/dot-stripCommonAffix.Rd new file mode 100644 index 00000000..47067d9f --- /dev/null +++ b/man/dot-stripCommonAffix.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_dataprocess_plots.R +\name{.stripCommonAffix} +\alias{.stripCommonAffix} +\title{Drop the prefix that every condition name shares} +\usage{ +.stripCommonAffix(names) +} +\arguments{ +\item{names}{character, condition names in plotting order} +} +\value{ +list with `labels` (shortened) and `prefix` (what was removed, "" when + nothing is shared) +} +\description{ +Condition names in real designs are usually built from a common stem plus a +distinguishing tail -- "Cyno_Colon_Timepoint_0hr", "Cyno_Colon_Timepoint_12hrs". +Only the tail identifies the block, but the shared stem is what consumes the +horizontal room, so it is dropped from the in-panel label and reported once in +the x-axis title instead. No information is lost from the static image. +} +\keyword{internal} diff --git a/man/dot-wrapConditionLabels.Rd b/man/dot-wrapConditionLabels.Rd new file mode 100644 index 00000000..b65bfb94 --- /dev/null +++ b/man/dot-wrapConditionLabels.Rd @@ -0,0 +1,24 @@ +% 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 onto several lines so they fit their slot} +\usage{ +.wrapConditionLabels(names, chars) +} +\arguments{ +\item{names}{character, condition names} + +\item{chars}{maximum characters per line} +} +\value{ +character, `names` unchanged when they all already fit +} +\description{ +Used only for what `.stripCommonAffix()` and shrinking the font cannot fix. +`strwrap()` breaks only at whitespace and condition names are usually +underscore-delimited, so separators are turned into break opportunities here. +A single token wider than the slot cannot be broken and is truncated; the +untruncated name stays available in the Plotly hover. +} +\keyword{internal} From 80ae97f52e9b4cc98f8b0b6c065ab7d743f9b2c5 Mon Sep 17 00:00:00 2001 From: Rudhik1904 Date: Sat, 5 Sep 2026 23:19:49 -0500 Subject: [PATCH 3/6] fix(dataProcessPlots): Honor every documented legend.position value Only "right" worked. The placement was hard-coded to a right-side vertical legend in .convertGgplot2Plotly(), so "left", "top" and "bottom" were accepted and then ignored -- and because the ggplot theme *was* set to the requested side, ggplotly reserved a band there that nothing went on to occupy. legend.position = "top" therefore pushed the title into the middle of the canvas and compressed the panel, which is the failure the surrounding work exists to fix. plotly::layout() defers into layoutAttrs and merges at build time, which is why this looked correct under inspection of x$layout. Placement now lives in .applyLegendPositionPlotly(), which maps each value to an anchor and orientation that agree with the theme, and gives "left" and "bottom" margin of their own to sit outside the axis furniture. It is applied last, after every .fix*Plotly() step. Those helpers rewrite showlegend on individual traces -- .fixCensoredPointsLegendProfilePlotsPlotly() turns the detected and censored entries back on -- so anything deciding whether the legend is drawn has to run after them. For "none" both the layout flag and every trace are cleared. Only the layout flag was set before, which happens to be what plotly.js honours, so the legend was hidden but sat over traces still marked visible. legend.position is now validated at entry, so an unsupported value fails instead of silently falling back. Documented that only the side placements scroll. A horizontal legend grows rather than scrolling, so "top" and "bottom" can still crowd the panel on proteins with many features; they are a trade-off rather than an equivalent choice. Co-Authored-By: Claude Opus 5 (1M context) --- R/dataProcessPlots.R | 131 ++++++++++++++++++-------- inst/tinytest/test_dataProcessPlots.R | 66 +++++++++++++ man/dataProcessPlots.Rd | 9 +- 3 files changed, 162 insertions(+), 44 deletions(-) diff --git a/R/dataProcessPlots.R b/R/dataProcessPlots.R index c580cde8..524ecb0b 100644 --- a/R/dataProcessPlots.R +++ b/R/dataProcessPlots.R @@ -75,9 +75,12 @@ #' If address=FALSE, plot will be not saved as pdf file but showed in window. #' @param legend.position position of the feature legend in the Plotly output of #' Profile Plot and QC Plot: "right" (default), "left", "top", "bottom", or "none" -#' to hide it. A side-mounted legend is scrollable in Plotly, so a protein with many -#' features can no longer cover the plot. Only affects \code{isPlotly = TRUE}; the -#' ggplot2 (PDF) output keeps its legend above the graph. +#' to hide it. Only affects \code{isPlotly = TRUE}; the ggplot2 (PDF) output keeps +#' its legend above the graph. Note that only the side placements ("right" and +#' "left") are scrollable in Plotly, which is what stops a protein with many +#' features from covering the plot. A horizontal legend grows instead of +#' scrolling, so "top" and "bottom" can still crowd the panel on feature-rich +#' proteins. #' @param width.plotly width in pixels of the Plotly output. Default is 1400, which #' matches the container MSstatsShiny reserves for these plots. Only affects #' \code{isPlotly = TRUE}; the PDF output is sized by \code{width}. @@ -135,6 +138,9 @@ dataProcessPlots = function( checkmate::assertChoice(type, c("PROFILEPLOT", "QCPLOT", "CONDITIONPLOT"), .var.name = "type") + checkmate::assertChoice(legend.position, + c("right", "left", "top", "bottom", "none"), + .var.name = "legend.position") if (as.character(address) == "FALSE") { if (which.Protein == "all") { stop("** Cannnot generate all plots in a screen. Please set one protein at a time.") @@ -173,6 +179,8 @@ dataProcessPlots = function( if(toupper(featureName) == "NA") { og_plotly_plot = .retainCensoredDataPoints(og_plotly_plot) } + og_plotly_plot = .applyLegendPositionPlotly(og_plotly_plot, + legend.position) plotly_plots = c(plotly_plots, list(og_plotly_plot)) } } @@ -189,6 +197,8 @@ dataProcessPlots = function( if(toupper(featureName) == "NA") { summ_plotly_plot = .retainCensoredDataPoints(summ_plotly_plot) } + summ_plotly_plot = .applyLegendPositionPlotly(summ_plotly_plot, + legend.position) plotly_plots = c(plotly_plots, list(summ_plotly_plot)) } } @@ -212,6 +222,7 @@ dataProcessPlots = function( width = width.plotly, height = height) plotly_plot = .fixLegendPlotlyPlotsDataprocess(plotly_plot) plotly_plot = .fixConditionLabelHoverPlotly(plotly_plot, plot) + plotly_plot = .applyLegendPositionPlotly(plotly_plot, legend.position) plotly_plots[[i]] = list(plotly_plot) } if(address != FALSE) { @@ -234,6 +245,7 @@ dataProcessPlots = function( plotly_plot <- .convertGgplot2Plotly(plot, legend_position = legend.position, width = width.plotly, height = height) plotly_plot = .fixLegendPlotlyPlotsDataprocess(plotly_plot) + plotly_plot = .applyLegendPositionPlotly(plotly_plot, legend.position) plotly_plots[[i]] = list(plotly_plot) } if(address != FALSE) { @@ -688,51 +700,88 @@ dataProcessPlots = function( #' converter for plots from ggplot to plotly #' -#' `ggplotly()` reserves the legend band from the ggplot theme, so the theme has -#' to agree with the `layout()` override below. Leaving the theme at "top" while -#' moving the legend to the side leaves a dead band across the top and squeezes -#' the panel into the lower-left corner. -#' -#' The legend is mounted on the side rather than below the panel because plotly -#' scrolls an over-tall vertical legend instead of growing it. A protein with -#' hundreds of features can then no longer cover the plot, and no entries are -#' dropped. +#' `ggplotly()` reserves the legend band from the ggplot theme, so the theme is +#' set to the requested position here and the matching plotly placement is +#' applied by `.applyLegendPositionPlotly()` once post-processing is done. The +#' two have to agree: a theme saying "top" under a legend drawn on the right +#' leaves a dead band across the top and squeezes the panel into the corner. #' @noRd .convertGgplot2Plotly = function(plot, tips = "all", legend_position = "right", width = 1400, height = 600) { plot = plot + theme(legend.position = legend_position) converted_plot <- ggplotly(plot, tooltip = tips, width = width, height = height) - converted_plot <- plotly::layout( - converted_plot, - # Room for the title, which the top-mounted legend used to sit on. - margin = list(t = 60), - title = list( - font = list( - size = 18 - ) - ), - xaxis = list( - titlefont = list( - size = 15 # Set the font size for the x-axis label - ) - ), - legend = list( - x = 1.02, # Just outside the panel on the right - y = 1, - xanchor = "left", - orientation = "v", # Vertical, so plotly makes it scrollable - font = list( - size = 10 # Set the font size for legend item labels - ), - title = list( - font = list( - size = 12 # Set the font size for the legend title - ) - ) + plotly::layout( + converted_plot, + title = list( + font = list( + size = 18 + ) + ), + xaxis = list( + titlefont = list( + size = 15 # Set the font size for the x-axis label ) - ) - converted_plot + ) + ) +} + + +#' place the legend, after every other post-processing step has run +#' +#' Applied last on purpose. The `.fix*Plotly()` helpers rewrite `showlegend` on +#' individual traces -- `.fixCensoredPointsLegendProfilePlotsPlotly()` turns the +#' detected and censored entries back on -- so anything deciding whether the +#' legend is drawn has to run after them or be undone by them. +#' +#' Only the vertical placements get plotly's scrolling behaviour, which is what +#' keeps a legend with hundreds of features from covering the plot. A horizontal +#' legend grows instead of scrolling, so "top" and "bottom" reintroduce that on +#' feature-rich proteins. They are offered because they are sometimes what a +#' caller wants, not because they are equivalent to the side placements. +#' +#' @param plot converted plotly plot +#' @param legend_position one of "right", "left", "top", "bottom", "none" +#' @noRd +.applyLegendPositionPlotly = function(plot, legend_position = "right") { + legend_position = match.arg(as.character(legend_position), + c("right", "left", "top", "bottom", "none")) + if (legend_position == "none") { + # layout$showlegend alone is enough for plotly.js today, but it leaves + # traces marked visible under a layout that says otherwise. Clear both + # so the request does not depend on which one plotly happens to honour. + for (i in seq_along(plot$x$data)) { + plot$x$data[[i]]$showlegend <- FALSE + } + return(plotly::layout(plot, showlegend = FALSE, + margin = list(t = 60))) + } + spec = switch( + legend_position, + right = list( + legend = list(x = 1.02, y = 1, xanchor = "left", yanchor = "top", + orientation = "v"), + margin = list(t = 60)), + left = list( + legend = list(x = -0.08, y = 1, xanchor = "right", yanchor = "top", + orientation = "v"), + # The y-axis title and tick labels already occupy the left edge, so + # the legend needs margin of its own to sit outside them. + margin = list(t = 60, l = 240)), + top = list( + legend = list(x = 0, y = 1.03, xanchor = "left", yanchor = "bottom", + orientation = "h"), + # Deep enough for the title and a wrapped horizontal legend under it. + margin = list(t = 130)), + bottom = list( + legend = list(x = 0, y = -0.18, xanchor = "left", yanchor = "top", + orientation = "h"), + margin = list(t = 60, b = 130)) + ) + spec$legend$font = list(size = 10) + spec$legend$title = list(font = list(size = 12)) + plotly::layout(plot, showlegend = TRUE, legend = spec$legend, + margin = spec$margin) } .retainCensoredDataPoints = function(plot) { diff --git a/inst/tinytest/test_dataProcessPlots.R b/inst/tinytest/test_dataProcessPlots.R index 6e6352fb..533a0464 100644 --- a/inst/tinytest/test_dataProcessPlots.R +++ b/inst/tinytest/test_dataProcessPlots.R @@ -81,3 +81,69 @@ invisible(capture.output(suppressWarnings( ))) expect_true(any(grepl("ConditionPlot.*\\.zip$", list.files(tmp_dir2)))) unlink(tmp_dir2, recursive = TRUE) + +# Test 10: every documented legend.position is honoured ---------------------- +# Regression test. The placement used to be hard-coded to a right-side vertical +# legend, so "left", "top" and "bottom" were accepted and then ignored -- and +# because the ggplot theme *was* set to the requested side, ggplotly reserved a +# band there that nothing occupied, squeezing the panel. plotly::layout() defers +# into layoutAttrs, so these have to be checked after plotly_build(). + +legend_spec = function(position) { + plot = suppressWarnings( + dataProcessPlots(QuantData, type = "ProfilePlot", + which.Protein = protein_name, summaryPlot = FALSE, + address = FALSE, isPlotly = TRUE, + legend.position = position) + )[[1]] + plotly::plotly_build(plot)$x$layout +} + +spec_right = legend_spec("right") +expect_true(spec_right$showlegend) +expect_equal(spec_right$legend$orientation, "v") +expect_true(spec_right$legend$x > 1) + +spec_left = legend_spec("left") +expect_true(spec_left$showlegend) +expect_equal(spec_left$legend$orientation, "v") +expect_true(spec_left$legend$x < 0) + +spec_top = legend_spec("top") +expect_true(spec_top$showlegend) +expect_equal(spec_top$legend$orientation, "h") +expect_true(spec_top$legend$y > 1) + +spec_bottom = legend_spec("bottom") +expect_true(spec_bottom$showlegend) +expect_equal(spec_bottom$legend$orientation, "h") +expect_true(spec_bottom$legend$y < 0) + +# The four placements have to differ from one another, which is the thing the +# original bug got wrong while still looking correct for the default. +expect_false(isTRUE(all.equal(spec_right$legend, spec_left$legend))) +expect_false(isTRUE(all.equal(spec_top$legend, spec_bottom$legend))) +expect_false(isTRUE(all.equal(spec_right$legend, spec_top$legend))) + +# Test 11: legend.position = "none" hides the legend outright ---------------- +# Both the layout flag and every trace, because the post-processing helpers turn +# individual traces back on after conversion. .fixCensoredPointsLegendProfile- +# PlotsPlotly() in particular re-enables the detected and censored entries, so a +# layout flag on its own leaves traces marked visible underneath it. +plot_none = suppressWarnings( + dataProcessPlots(QuantData, type = "ProfilePlot", + which.Protein = protein_name, summaryPlot = FALSE, + address = FALSE, isPlotly = TRUE, + legend.position = "none") +)[[1]] +built_none = plotly::plotly_build(plot_none) +expect_false(built_none$x$layout$showlegend) +expect_true(all(!vapply(built_none$x$data, + function(trace) isTRUE(trace$showlegend), logical(1)))) + +# Test 12: an undocumented legend.position is rejected rather than ignored ---- +expect_error( + dataProcessPlots(QuantData, type = "ProfilePlot", + which.Protein = protein_name, address = FALSE, + isPlotly = TRUE, legend.position = "middle") +) diff --git a/man/dataProcessPlots.Rd b/man/dataProcessPlots.Rd index 9a5a877c..06df3198 100644 --- a/man/dataProcessPlots.Rd +++ b/man/dataProcessPlots.Rd @@ -120,9 +120,12 @@ The other assigned folder has to be existed under the current working directory. \item{legend.position}{position of the feature legend in the Plotly output of Profile Plot and QC Plot: "right" (default), "left", "top", "bottom", or "none" -to hide it. A side-mounted legend is scrollable in Plotly, so a protein with many -features can no longer cover the plot. Only affects \code{isPlotly = TRUE}; the -ggplot2 (PDF) output keeps its legend above the graph.} +to hide it. Only affects \code{isPlotly = TRUE}; the ggplot2 (PDF) output keeps +its legend above the graph. Note that only the side placements ("right" and +"left") are scrollable in Plotly, which is what stops a protein with many +features from covering the plot. A horizontal legend grows instead of +scrolling, so "top" and "bottom" can still crowd the panel on feature-rich +proteins.} \item{width.plotly}{width in pixels of the Plotly output. Default is 1400, which matches the container MSstatsShiny reserves for these plots. Only affects From c566c192d361717486eeb34298c35c14f159609c Mon Sep 17 00:00:00 2001 From: Rudhik Shah <45579871+Rudhik1904@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:42:10 -0500 Subject: [PATCH 4/6] Update R/utils_dataprocess_plots.R Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- R/utils_dataprocess_plots.R | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/R/utils_dataprocess_plots.R b/R/utils_dataprocess_plots.R index 59a20a42..089f8c22 100644 --- a/R/utils_dataprocess_plots.R +++ b/R/utils_dataprocess_plots.R @@ -99,7 +99,10 @@ #' @return integer, at least 1 #' @keywords internal .conditionSlotChars = function(n_conditions, n_facets, width, text.size) { - if (!is.numeric(width) || width <= 0 || n_conditions < 1L) { + if (!is.numeric(width) || length(width) != 1L || is.na(width) || + width <= 0 || n_conditions < 1L) { + return(.Machine$integer.max) + } return(.Machine$integer.max) } # ~1.1in of the canvas goes to the y-axis title, tick labels and margins; From 651472461601b904c39bfc3ad8eac2b876d63d71 Mon Sep 17 00:00:00 2001 From: Rudhik1904 Date: Tue, 8 Sep 2026 19:28:45 -0500 Subject: [PATCH 5/6] fix(utils_dataprocess_plots): Remove redundant return statement in width validation --- R/utils_dataprocess_plots.R | 2 -- 1 file changed, 2 deletions(-) diff --git a/R/utils_dataprocess_plots.R b/R/utils_dataprocess_plots.R index 089f8c22..750d3f7f 100644 --- a/R/utils_dataprocess_plots.R +++ b/R/utils_dataprocess_plots.R @@ -102,8 +102,6 @@ if (!is.numeric(width) || length(width) != 1L || is.na(width) || width <= 0 || n_conditions < 1L) { return(.Machine$integer.max) - } - return(.Machine$integer.max) } # ~1.1in of the canvas goes to the y-axis title, tick labels and margins; # what is left is split across the facets and then across the conditions. From 7f2fcfa8dcba012a8c9eb78806baf358c2b7b067 Mon Sep 17 00:00:00 2001 From: Rudhik1904 Date: Sat, 19 Sep 2026 18:21:00 -0500 Subject: [PATCH 6/6] Address code review comments --- R/dataProcessPlots.R | 208 +++++++------------ R/utils_dataprocess_plots.R | 129 ++++++------ inst/NEWS.rd | 6 +- inst/tinytest/test_dataProcessPlots.R | 140 ++++++++----- inst/tinytest/test_utils_dataprocess_plots.R | 103 +++++++-- man/dataProcessPlots.Rd | 31 ++- man/dot-conditionSlotChars.Rd | 37 ---- man/dot-conditionXlab.Rd | 21 -- man/dot-layoutConditionLabels.Rd | 34 --- man/dot-makeConditionPlot.Rd | 6 +- man/dot-makeProfilePlot.Rd | 58 ------ man/dot-makeQCPlot.Rd | 83 -------- man/dot-makeSummaryProfilePlot.Rd | 50 ----- man/dot-stripCommonAffix.Rd | 23 -- man/dot-wrapConditionLabels.Rd | 24 --- 15 files changed, 339 insertions(+), 614 deletions(-) delete mode 100644 man/dot-conditionSlotChars.Rd delete mode 100644 man/dot-conditionXlab.Rd delete mode 100644 man/dot-layoutConditionLabels.Rd delete mode 100644 man/dot-makeProfilePlot.Rd delete mode 100644 man/dot-makeQCPlot.Rd delete mode 100644 man/dot-makeSummaryProfilePlot.Rd delete mode 100644 man/dot-stripCommonAffix.Rd delete mode 100644 man/dot-wrapConditionLabels.Rd diff --git a/R/dataProcessPlots.R b/R/dataProcessPlots.R index 524ecb0b..f254c799 100644 --- a/R/dataProcessPlots.R +++ b/R/dataProcessPlots.R @@ -1,3 +1,8 @@ +# Width of the Plotly canvas in CSS pixels, matching the container MSstatsShiny +# reserves for these plots. The saved PDF is sized separately, by `width`. +PLOTLY_CANVAS_WIDTH = 1400 + + #' Visualization for explanatory data analysis #' #' @description To illustrate the quantitative data after data-preprocessing and @@ -40,13 +45,21 @@ #' 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. In Profile Plot and QC plot the rotation applies to the +#' ggplot2/PDF output only: \code{ggplotly()} does not carry the rotation of +#' the condition labels through, so \code{isPlotly = TRUE} draws them +#' horizontally and fits them to the available room instead. Condition plot +#' rotates its x-axis labels in both outputs. #' @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. #' @param dot.size.condition size of dots in condition plot. Default is 3. -#' @param width width of the saved file in pixels. Default is 800 pixels. +#' @param width width of the saved PDF file in pixels, converted at 72 pixels +#' per inch, so the default 800 is an 11.1 inch page. Does not affect the Plotly +#' output, whose canvas is fixed at the width MSstatsShiny reserves for these +#' plots. #' @param height height of the saved file in pixels. Default is 600 pixels. +#' Applies to both the PDF and the Plotly output. #' @param which.Protein Protein list to draw plots. List can be names of Proteins #' or order numbers of Proteins from levels(data$FeatureLevelData$PROTEIN). #' Default is "all", which generates all plots for each protein. @@ -73,17 +86,6 @@ #' 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 the Plotly output of -#' Profile Plot and QC Plot: "right" (default), "left", "top", "bottom", or "none" -#' to hide it. Only affects \code{isPlotly = TRUE}; the ggplot2 (PDF) output keeps -#' its legend above the graph. Note that only the side placements ("right" and -#' "left") are scrollable in Plotly, which is what stops a protein with many -#' features from covering the plot. A horizontal legend grows instead of -#' scrolling, so "top" and "bottom" can still crowd the panel on feature-rich -#' proteins. -#' @param width.plotly width in pixels of the Plotly output. Default is 1400, which -#' matches the container MSstatsShiny reserves for these plots. Only affects -#' \code{isPlotly = TRUE}; the PDF output is sized by \code{width}. #' #' @details #' \itemize{ @@ -125,8 +127,7 @@ 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, - legend.position = "right", width.plotly = 1400 + remove_uninformative_feature_outlier = FALSE, address = "", isPlotly = FALSE ) { PROTEIN = Protein = NULL @@ -138,9 +139,6 @@ dataProcessPlots = function( checkmate::assertChoice(type, c("PROFILEPLOT", "QCPLOT", "CONDITIONPLOT"), .var.name = "type") - checkmate::assertChoice(legend.position, - c("right", "left", "top", "bottom", "none"), - .var.name = "legend.position") if (as.character(address) == "FALSE") { if (which.Protein == "all") { stop("** Cannnot generate all plots in a screen. Please set one protein at a time.") @@ -159,8 +157,7 @@ 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, - width.plotly) + summaryPlot, remove_uninformative_feature_outlier, address, isPlotly) plotly_plots = list() if(isPlotly) { og_plotly_plot = NULL @@ -169,8 +166,7 @@ dataProcessPlots = function( 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"), - legend_position = legend.position, - width = width.plotly, height = height) + width = PLOTLY_CANVAS_WIDTH, height = height) og_plotly_plot = .fixLegendPlotlyPlotsDataprocess(og_plotly_plot) og_plotly_plot = .fixCensoredPointsLegendProfilePlotsPlotly(og_plotly_plot) og_plotly_plot = .fixErrorBarCapsPlotly(og_plotly_plot) @@ -179,8 +175,7 @@ dataProcessPlots = function( if(toupper(featureName) == "NA") { og_plotly_plot = .retainCensoredDataPoints(og_plotly_plot) } - og_plotly_plot = .applyLegendPositionPlotly(og_plotly_plot, - legend.position) + og_plotly_plot = .applyLegendPositionPlotly(og_plotly_plot) plotly_plots = c(plotly_plots, list(og_plotly_plot)) } } @@ -188,8 +183,7 @@ dataProcessPlots = function( 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"), - legend_position = legend.position, - width = width.plotly, height = height) + width = PLOTLY_CANVAS_WIDTH, height = height) summ_plotly_plot = .fixLegendPlotlyPlotsDataprocess(summ_plotly_plot) summ_plotly_plot = .fixCensoredPointsLegendProfilePlotsPlotly(summ_plotly_plot) summ_plotly_plot = .fixErrorBarCapsPlotly(summ_plotly_plot) @@ -197,14 +191,13 @@ dataProcessPlots = function( if(toupper(featureName) == "NA") { summ_plotly_plot = .retainCensoredDataPoints(summ_plotly_plot) } - summ_plotly_plot = .applyLegendPositionPlotly(summ_plotly_plot, - legend.position) + summ_plotly_plot = .applyLegendPositionPlotly(summ_plotly_plot) plotly_plots = c(plotly_plots, list(summ_plotly_plot)) } } if(address != FALSE) { - .savePlotlyPlotHTML(plotly_plots,address,"ProfilePlot" ,width, height) + .savePlotlyPlotHTML(plotly_plots,address,"ProfilePlot" ,PLOTLY_CANVAS_WIDTH, height) } plotly_plots } @@ -213,20 +206,19 @@ 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, width.plotly) + which.Protein, address, isPlotly) plotly_plots <- vector("list", length(plots)) if(isPlotly) { for(i in seq_along(plots)) { plot <- plots[[i]] - plotly_plot <- .convertGgplot2Plotly(plot, legend_position = legend.position, - width = width.plotly, height = height) + plotly_plot <- .convertGgplot2Plotly(plot, width = PLOTLY_CANVAS_WIDTH, height = height) plotly_plot = .fixLegendPlotlyPlotsDataprocess(plotly_plot) plotly_plot = .fixConditionLabelHoverPlotly(plotly_plot, plot) - plotly_plot = .applyLegendPositionPlotly(plotly_plot, legend.position) + plotly_plot = .applyLegendPositionPlotly(plotly_plot) plotly_plots[[i]] = list(plotly_plot) } if(address != FALSE) { - .savePlotlyPlotHTML(plotly_plots,address,"QCPlot" ,width, height) + .savePlotlyPlotHTML(plotly_plots,address,"QCPlot" ,PLOTLY_CANVAS_WIDTH, height) } plotly_plots <- unlist(plotly_plots, recursive = FALSE) plotly_plots @@ -242,14 +234,13 @@ dataProcessPlots = function( if(isPlotly) { for(i in seq_along(plots)) { plot <- plots[[i]] - plotly_plot <- .convertGgplot2Plotly(plot, legend_position = legend.position, - width = width.plotly, height = height) + plotly_plot <- .convertGgplot2Plotly(plot, width = PLOTLY_CANVAS_WIDTH, height = height) plotly_plot = .fixLegendPlotlyPlotsDataprocess(plotly_plot) - plotly_plot = .applyLegendPositionPlotly(plotly_plot, legend.position) + plotly_plot = .applyLegendPositionPlotly(plotly_plot) plotly_plots[[i]] = list(plotly_plot) } if(address != FALSE) { - .savePlotlyPlotHTML(plotly_plots,address,"ConditionPlot" ,width, height) + .savePlotlyPlotHTML(plotly_plots,address,"ConditionPlot" ,PLOTLY_CANVAS_WIDTH, height) } plotly_plots <- unlist(plotly_plots, recursive = FALSE) plotly_plots @@ -264,8 +255,7 @@ 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, - width.plotly = 1400 + originalPlot, summaryPlot, remove_uninformative_feature_outlier, address, isPlotly ) { ABUNDANCE = PROTEIN = feature_quality = is_outlier = Protein = GROUP = NULL SUBJECT = LABEL = RUN = xtabs = PEPTIDE = FEATURE = NULL @@ -315,20 +305,22 @@ dataProcessPlots = function( y.limup = ifelse(is.numeric(ylimUp), ylimUp, ceiling(max(processed$ABUNDANCE, na.rm = TRUE) + 3)) y.limdown = ifelse(is.numeric(ylimDown), ylimDown, -1) - # Fit the condition names to the room each condition actually gets. Labels - # that already fit come back untouched; the rest are shortened, shrunk and - # wrapped, and any extra lines are paid for with headroom above the data. - n_facets = data.table::uniqueN(processed$LABEL) - condition.layout = .layoutConditionLabels( - levels(tempGroupName$GROUP), n_facets, - if (isPlotly) width.plotly else width, text.size, text.angle) - if (!is.numeric(ylimUp)) { + # Laid out for the Plotly output only; the PDF keeps the full names, where + # text.angle still works. + condition.names = levels(tempGroupName$GROUP) + condition.layout = if (isPlotly) { + .layoutConditionLabels(condition.names, + data.table::uniqueN(processed$LABEL), + PLOTLY_CANVAS_WIDTH, text.size) + } else NULL + if (!is.numeric(ylimUp) && !is.null(condition.layout)) { y.limup = y.limup + (condition.layout$n_lines - 1) * 0.9 } groupName = data.frame(RUN = c(0, lineNameAxis) + groupAxis / 2 + 0.5, ABUNDANCE = rep(y.limup - 0.5, length(groupAxis)), - Name = levels(tempGroupName$GROUP), - Label = condition.layout$labels) + Name = condition.names, + Label = if (is.null(condition.layout)) condition.names + else condition.layout$labels) if ("is_labeled_ref" %in% colnames(processed)) { @@ -481,8 +473,7 @@ 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, - width.plotly = 1400 + text.angle, legend.size, dot.size.profile, width, height, protein, address, isPlotly ) { GROUP = SUBJECT = RUN = LABEL = PROTEIN = NULL @@ -524,17 +515,22 @@ dataProcessPlots = function( groupAxis = as.numeric(xtabs(~GROUP, tempGroupName)) cumGroupAxis = cumsum(groupAxis) lineNameAxis = cumGroupAxis[-nlevels(tempGroupName$GROUP)] - n_facets = data.table::uniqueN(processed$LABEL) - condition.layout = .layoutConditionLabels( - levels(tempGroupName$GROUP), n_facets, - if (isPlotly) width.plotly else width, text.size, text.angle) - if (!is.numeric(ylimUp)) { + # Laid out for the Plotly output only; the PDF keeps the full names, where + # text.angle still works. + condition.names = levels(tempGroupName$GROUP) + condition.layout = if (isPlotly) { + .layoutConditionLabels(condition.names, + data.table::uniqueN(processed$LABEL), + PLOTLY_CANVAS_WIDTH, text.size) + } else NULL + if (!is.numeric(ylimUp) && !is.null(condition.layout)) { y.limup = y.limup + (condition.layout$n_lines - 1) * 0.9 } groupName = data.frame(RUN = c(0, lineNameAxis) + groupAxis / 2 + 0.5, ABUNDANCE = rep(y.limup - 0.5, length(groupAxis)), - Name = levels(tempGroupName$GROUP), - Label = condition.layout$labels) + Name = condition.names, + Label = if (is.null(condition.layout)) condition.names + else condition.layout$labels) if (!isPlotly) { savePlot(address, "QCPlot", width, height) } @@ -670,9 +666,8 @@ dataProcessPlots = function( #' restore the untruncated condition name in the Plotly hover #' -#' `.layoutConditionLabels()` may have shortened what is drawn in the panel. The -#' condition labels arrive as a single text-mode trace, so the full names can be -#' put back on hover without disturbing the drawn text. +#' The condition labels arrive as a single text-mode trace, so the full names +#' can be put back on hover without disturbing the drawn text. #' @param plot converted plotly plot #' @param ggplot_obj the ggplot it was converted from, carrying the drawn #' `Label` and the untruncated `Name` on its condition label layer @@ -720,7 +715,7 @@ dataProcessPlots = function( ), xaxis = list( titlefont = list( - size = 15 # Set the font size for the x-axis label + size = 15 ) ) ) @@ -729,59 +724,21 @@ dataProcessPlots = function( #' place the legend, after every other post-processing step has run #' -#' Applied last on purpose. The `.fix*Plotly()` helpers rewrite `showlegend` on -#' individual traces -- `.fixCensoredPointsLegendProfilePlotsPlotly()` turns the -#' detected and censored entries back on -- so anything deciding whether the -#' legend is drawn has to run after them or be undone by them. -#' -#' Only the vertical placements get plotly's scrolling behaviour, which is what -#' keeps a legend with hundreds of features from covering the plot. A horizontal -#' legend grows instead of scrolling, so "top" and "bottom" reintroduce that on -#' feature-rich proteins. They are offered because they are sometimes what a -#' caller wants, not because they are equivalent to the side placements. +#' Applied last on purpose: the `.fix*Plotly()` helpers rewrite `showlegend` on +#' individual traces, so anything deciding whether the legend is drawn has to run +#' after them or be undone by them. Mounted on the right because only the +#' vertical placements get plotly's scrolling behaviour, which is what keeps a +#' legend with hundreds of features from covering the plot. #' #' @param plot converted plotly plot -#' @param legend_position one of "right", "left", "top", "bottom", "none" #' @noRd -.applyLegendPositionPlotly = function(plot, legend_position = "right") { - legend_position = match.arg(as.character(legend_position), - c("right", "left", "top", "bottom", "none")) - if (legend_position == "none") { - # layout$showlegend alone is enough for plotly.js today, but it leaves - # traces marked visible under a layout that says otherwise. Clear both - # so the request does not depend on which one plotly happens to honour. - for (i in seq_along(plot$x$data)) { - plot$x$data[[i]]$showlegend <- FALSE - } - return(plotly::layout(plot, showlegend = FALSE, - margin = list(t = 60))) - } - spec = switch( - legend_position, - right = list( - legend = list(x = 1.02, y = 1, xanchor = "left", yanchor = "top", - orientation = "v"), - margin = list(t = 60)), - left = list( - legend = list(x = -0.08, y = 1, xanchor = "right", yanchor = "top", - orientation = "v"), - # The y-axis title and tick labels already occupy the left edge, so - # the legend needs margin of its own to sit outside them. - margin = list(t = 60, l = 240)), - top = list( - legend = list(x = 0, y = 1.03, xanchor = "left", yanchor = "bottom", - orientation = "h"), - # Deep enough for the title and a wrapped horizontal legend under it. - margin = list(t = 130)), - bottom = list( - legend = list(x = 0, y = -0.18, xanchor = "left", yanchor = "top", - orientation = "h"), - margin = list(t = 60, b = 130)) - ) - spec$legend$font = list(size = 10) - spec$legend$title = list(font = list(size = 12)) - plotly::layout(plot, showlegend = TRUE, legend = spec$legend, - margin = spec$margin) +.applyLegendPositionPlotly = function(plot) { + plotly::layout( + plot, showlegend = TRUE, + legend = list(x = 1.02, y = 1, xanchor = "left", yanchor = "top", + orientation = "v", font = list(size = 10), + title = list(font = list(size = 12))), + margin = list(t = 60)) } .retainCensoredDataPoints = function(plot) { @@ -818,10 +775,8 @@ dataProcessPlots = function( first_false_index <- which(df$legend_entries == "FALSE")[1] first_true_index <- which(df$legend_entries == "TRUE")[1] - # Pin the two shape entries above the feature list. The feature legend - # scrolls once a protein has more features than fit, and these two are the - # key to reading the plot -- left at the default rank they end up below the - # fold. Lower legendrank sorts first; plotly's default is 1000. + # Pin the two shape entries above the scrolling feature list; lower + # legendrank sorts first, and plotly's default is 1000. if (!is.na(first_false_index)) { plot$x$data[[first_false_index]]$name <- "Detected data" plot$x$data[[first_false_index]]$showlegend <- TRUE @@ -872,20 +827,17 @@ dataProcessPlots = function( plot } +#' wrap converted plots in sized containers for the saved HTML +#' +#' The container has to be at least as wide as the widget inside it. Pinned at +#' 800 it cropped a 1400px plot, cutting off the side legend. +#' @noRd .getPlotlyPlotHTML = function(plots, width, height) { - doc <- htmltools::tagList(lapply(plots,function(x) htmltools::div(x, style = "float:left;width:100%;"))) - # Set a specific width for each plot - plot_width <- 800 - plot_height <- 600 - - # Create a div for each plot with style settings divs <- lapply(plots, function(x) { - htmltools::div(x, style = paste0("width:", plot_width, "px; height:", plot_height, "px; margin: 10px;")) + htmltools::div(x, style = paste0("width:", width, "px; height:", height, + "px; margin: 10px;")) }) - - # Combine the divs into a tagList - doc <- htmltools::tagList(divs) - doc + htmltools::tagList(divs) } .savePlotlyPlotHTML = function(plots, address, file_name, width, height) { diff --git a/R/utils_dataprocess_plots.R b/R/utils_dataprocess_plots.R index 750d3f7f..c272b58f 100644 --- a/R/utils_dataprocess_plots.R +++ b/R/utils_dataprocess_plots.R @@ -35,16 +35,14 @@ #' Drop the prefix that every condition name shares #' -#' Condition names in real designs are usually built from a common stem plus a -#' distinguishing tail -- "Cyno_Colon_Timepoint_0hr", "Cyno_Colon_Timepoint_12hrs". -#' Only the tail identifies the block, but the shared stem is what consumes the -#' horizontal room, so it is dropped from the in-panel label and reported once in -#' the x-axis title instead. No information is lost from the static image. +#' Only the tail of "Study_Tissue_Timepoint_0hr" identifies the block, but the +#' shared stem is what consumes the horizontal room. The Plotly hover carries +#' the untruncated name. #' #' @param names character, condition names in plotting order #' @return list with `labels` (shortened) and `prefix` (what was removed, "" when #' nothing is shared) -#' @keywords internal +#' @noRd .stripCommonAffix = function(names) { names = as.character(names) unchanged = list(labels = names, prefix = "") @@ -78,11 +76,6 @@ #' Number of characters that fit in one condition's slot #' -#' Conditions tile the panel evenly, so each name gets `panel_width / -#' n_conditions` of room no matter how many runs it covers -- which is why -#' crowding is a function of name length and condition count, and never of the -#' number of samples per condition. -#' #' Width is estimated from `nchar` rather than 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 layout device-dependent and this @@ -97,7 +90,7 @@ #' @param width width of the canvas in pixels, read as CSS pixels at 96dpi #' @param text.size size of the condition labels #' @return integer, at least 1 -#' @keywords internal +#' @noRd .conditionSlotChars = function(n_conditions, n_facets, width, text.size) { if (!is.numeric(width) || length(width) != 1L || is.na(width) || width <= 0 || n_conditions < 1L) { @@ -106,9 +99,8 @@ # ~1.1in of the canvas goes to the y-axis title, tick labels and margins; # what is left is split across the facets and then across the conditions. panel_in = (width / 96 - 1.1) / max(n_facets, 1L) - # Only fill part of the slot: a label that fills it exactly touches its - # neighbours, and the first and last labels overhang the panel edge because - # they are centred on their block. + # Only fill part of the slot: a label filling it exactly touches its + # neighbours, and the end labels overhang the panel edge. slot_in = 0.85 * panel_in / n_conditions char_in = text.size * ggplot2::.pt * 0.53 / 72 if (slot_in <= 0 || char_in <= 0) { @@ -118,19 +110,44 @@ } +#' Shorten a string to `chars`, keeping both ends +#' +#' A head-only truncation is what makes two conditions sharing a stem render as +#' the same label, so the identifying tail is kept too. +#' +#' @param x character(1) +#' @param chars maximum characters to return +#' @return character(1), `x` unchanged when it already fits +#' @noRd +.ellipsize = function(x, chars) { + if (nchar(x) <= chars) { + return(x) + } + if (chars <= 3L) { + return(substr(x, 1L, max(1L, chars))) + } + keep = chars - 3L + head_n = keep %/% 2L + tail_n = keep - head_n + paste0(substr(x, 1L, head_n), "...", + substr(x, nchar(x) - tail_n + 1L, nchar(x))) +} + + #' Wrap condition names onto several lines so they fit their slot #' -#' Used only for what `.stripCommonAffix()` and shrinking the font cannot fix. #' `strwrap()` breaks only at whitespace and condition names are usually #' underscore-delimited, so separators are turned into break opportunities here. -#' A single token wider than the slot cannot be broken and is truncated; the -#' untruncated name stays available in the Plotly hover. +#' A single token wider than the slot cannot be broken and is shortened. Past +#' `max_lines` the remainder is folded into the last line rather than spilling +#' down the axis. #' #' @param names character, condition names #' @param chars maximum characters per line +#' @param max_lines maximum lines a single label may occupy #' @return character, `names` unchanged when they all already fit -#' @keywords internal -.wrapConditionLabels = function(names, chars) { +#' @noRd +.wrapConditionLabels = function(names, chars, max_lines = 3L) { names = as.character(names) if (all(nchar(names) <= chars)) { return(names) @@ -141,13 +158,8 @@ if (length(tokens) == 0L) { tokens = name } - tokens = vapply(tokens, function(token) { - if (nchar(token) > chars) { - paste0(substr(token, 1L, max(1L, chars - 3L)), "...") - } else { - token - } - }, character(1), USE.NAMES = FALSE) + tokens = vapply(tokens, .ellipsize, character(1), chars = chars, + USE.NAMES = FALSE) lines = character(0) current = "" for (token in tokens) { @@ -159,7 +171,13 @@ current = candidate } } - paste(c(lines, current), collapse = "\n") + lines = c(lines, current) + if (length(lines) > max_lines) { + kept = lines[seq_len(max_lines - 1L)] + rest = paste(lines[max_lines:length(lines)], collapse = "") + lines = c(kept, .ellipsize(rest, chars)) + } + paste(lines, collapse = "\n") }, character(1), USE.NAMES = FALSE) } @@ -172,20 +190,12 @@ #' #' @inheritParams .conditionSlotChars #' @param names character, condition names in plotting order -#' @param text.angle angle of the labels. A non-zero value is a deliberate choice -#' by the caller, so the layout is left alone. Note that rotation is not carried -#' through by `ggplotly()`, so it does not help the MSstatsShiny output. -#' @return list with `labels`, the `size` to draw them at, the `n_lines` they -#' occupy, and the `xaxis` title to use -#' @keywords internal -.layoutConditionLabels = function(names, n_facets, width, text.size, - text.angle = 0) { +#' @return list with `labels`, the `size` to draw them at, and the `n_lines` +#' they occupy +#' @noRd +.layoutConditionLabels = function(names, n_facets, width, text.size) { labels = as.character(names) - unchanged = list(labels = labels, size = text.size, n_lines = 1L, - xaxis = "MS runs") - if (!isTRUE(all.equal(as.numeric(text.angle), 0))) { - return(unchanged) - } + unchanged = list(labels = labels, size = text.size, n_lines = 1L) n_conditions = length(labels) if (n_conditions < 2L) { return(unchanged) @@ -194,11 +204,9 @@ .conditionSlotChars(n_conditions, n_facets, width, text.size)) { return(unchanged) } - xaxis = "MS runs" stripped = .stripCommonAffix(labels) if (nzchar(stripped$prefix)) { labels = stripped$labels - xaxis = paste0("MS runs (conditions: ", stripped$prefix, "*)") } # Shrink before wrapping: one legible line beats two cramped ones. The floor # is where shrinking stops buying fit and starts buying illegibility. @@ -210,26 +218,21 @@ } size = size - 0.25 } - labels = .wrapConditionLabels(labels, chars) + wrapped = .wrapConditionLabels(labels, chars) + # A shortening that collapses two conditions onto one string is worse than + # a crowded axis, so the full names are kept instead. + if (anyDuplicated(wrapped) == 0L) { + labels = wrapped + } list(labels = labels, size = size, - n_lines = max(lengths(strsplit(labels, "\n", fixed = TRUE))), - xaxis = xaxis) + n_lines = max(lengths(strsplit(labels, "\n", fixed = TRUE)))) } -#' Accessors for the condition label layout -#' -#' The builders are also called with `condition.layout = NULL` (nothing computed -#' a layout), in which case they fall back to the historical behaviour. +#' Font size the condition labels were laid out for, or the caller's #' @param layout result of `.layoutConditionLabels()`, or NULL -#' @keywords internal -.conditionXlab = function(layout) { - if (is.null(layout$xaxis)) "MS runs" else layout$xaxis -} - -#' @rdname dot-conditionXlab #' @param text.size size to fall back to -#' @keywords internal +#' @noRd .conditionTextSize = function(layout, text.size) { if (is.null(layout$size)) text.size else layout$size } @@ -238,7 +241,7 @@ #' @inheritParams dataProcessPlots #' @param input data.table #' @param is_censored TRUE if censored values were imputed -#' @keywords internal +#' @noRd .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, @@ -295,7 +298,7 @@ profile_plot = profile_plot + scale_linetype_manual(values = ss, guide = "none") profile_plot = profile_plot + - scale_x_continuous(.conditionXlab(condition.layout), breaks = cumGroupAxis) + + 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)) + @@ -349,7 +352,7 @@ #' Make summary profile plot #' @inheritParams dataProcessPlots #' @inheritParams .makeProfilePlot -#' @keywords internal +#' @noRd .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, @@ -397,7 +400,7 @@ scale_size_manual(values = c(1.7, 2), guide = "none") + scale_linetype_manual(values = c(rep(1, times = num_features - 1), 2), guide = "none") + - scale_x_continuous(.conditionXlab(condition.layout), breaks = cumGroupAxis) + + 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") + @@ -432,7 +435,7 @@ #' @inherit dataProcessPlots #' @param input data.table #' @param all_proteins character vector of protein names -#' @keywords internal +#' @noRd .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, @@ -451,7 +454,7 @@ geom_boxplot(aes(fill = .data$LABEL), outlier.shape = 1, outlier.size = 1.5) + scale_fill_manual(values = label.color, guide = "none") + - scale_x_discrete(.conditionXlab(condition.layout), breaks = cumGroupAxis) + + 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") + diff --git a/inst/NEWS.rd b/inst/NEWS.rd index 84302f6e..73d01bf9 100644 --- a/inst/NEWS.rd +++ b/inst/NEWS.rd @@ -4,9 +4,9 @@ \section{Version 4.22.0 (in development)}{ \itemize{ - \item \strong{Profile and QC plots}: Condition names no longer overlap each other. When a name is wider than the horizontal room its condition is given, the stem shared by every condition is moved to the x-axis title, the label font is reduced, and the remainder is wrapped. Plots whose condition labels already fit are unchanged. In the Plotly output the untruncated name is available on hover. - \item \strong{Profile and QC plots}: In the Plotly output the feature legend is now mounted beside the plot rather than above it, where Plotly makes an over-tall legend scrollable. Proteins with many features no longer have the legend cover the plot, and legend entries are no longer silently dropped. The new \code{legend.position} argument of \code{dataProcessPlots} repositions or hides it, and \code{width.plotly} sets the width of the Plotly canvas. - \item \strong{Bug fix}: \code{dataProcessPlots} ignored \code{width} and \code{height} when \code{isPlotly = TRUE}, always producing an 800x600 plot. + \item \strong{Profile and QC plots}: Condition names no longer overlap each other. When a name is wider than the horizontal room its condition is given, the stem shared by every condition is dropped, the label font is reduced, and the remainder is wrapped onto at most three lines. Plots whose condition labels already fit are unchanged. In the Plotly output the untruncated name is available on hover. + \item \strong{Profile and QC plots}: In the Plotly output the feature legend is now mounted beside the plot rather than above it, where Plotly makes an over-tall legend scrollable. Proteins with many features no longer have the legend cover the plot, and legend entries are no longer silently dropped. + \item \strong{Bug fix}: In the Plotly output \code{dataProcessPlots} ignored \code{height}, and the saved HTML pinned every plot inside a fixed 800x600 container, cropping anything wider than it. The container is now sized to the plot and \code{height} is honoured. } } diff --git a/inst/tinytest/test_dataProcessPlots.R b/inst/tinytest/test_dataProcessPlots.R index 533a0464..9bfa679d 100644 --- a/inst/tinytest/test_dataProcessPlots.R +++ b/inst/tinytest/test_dataProcessPlots.R @@ -82,68 +82,102 @@ invisible(capture.output(suppressWarnings( expect_true(any(grepl("ConditionPlot.*\\.zip$", list.files(tmp_dir2)))) unlink(tmp_dir2, recursive = TRUE) -# Test 10: every documented legend.position is honoured ---------------------- -# Regression test. The placement used to be hard-coded to a right-side vertical -# legend, so "left", "top" and "bottom" were accepted and then ignored -- and -# because the ggplot theme *was* set to the requested side, ggplotly reserved a -# band there that nothing occupied, squeezing the panel. plotly::layout() defers -# into layoutAttrs, so these have to be checked after plotly_build(). - -legend_spec = function(position) { +# Test 10: the Plotly legend is mounted on the right ------------------------ +# Regression test. The placement used to disagree with the ggplot theme, so +# ggplotly reserved a band that nothing occupied and squeezed the panel into the +# corner. plotly::layout() defers into layoutAttrs, so this has to be checked +# after plotly_build(). + +legend_spec = function() { plot = suppressWarnings( dataProcessPlots(QuantData, type = "ProfilePlot", which.Protein = protein_name, summaryPlot = FALSE, - address = FALSE, isPlotly = TRUE, - legend.position = position) + address = FALSE, isPlotly = TRUE) )[[1]] plotly::plotly_build(plot)$x$layout } -spec_right = legend_spec("right") +spec_right = legend_spec() expect_true(spec_right$showlegend) expect_equal(spec_right$legend$orientation, "v") expect_true(spec_right$legend$x > 1) -spec_left = legend_spec("left") -expect_true(spec_left$showlegend) -expect_equal(spec_left$legend$orientation, "v") -expect_true(spec_left$legend$x < 0) - -spec_top = legend_spec("top") -expect_true(spec_top$showlegend) -expect_equal(spec_top$legend$orientation, "h") -expect_true(spec_top$legend$y > 1) - -spec_bottom = legend_spec("bottom") -expect_true(spec_bottom$showlegend) -expect_equal(spec_bottom$legend$orientation, "h") -expect_true(spec_bottom$legend$y < 0) - -# The four placements have to differ from one another, which is the thing the -# original bug got wrong while still looking correct for the default. -expect_false(isTRUE(all.equal(spec_right$legend, spec_left$legend))) -expect_false(isTRUE(all.equal(spec_top$legend, spec_bottom$legend))) -expect_false(isTRUE(all.equal(spec_right$legend, spec_top$legend))) - -# Test 11: legend.position = "none" hides the legend outright ---------------- -# Both the layout flag and every trace, because the post-processing helpers turn -# individual traces back on after conversion. .fixCensoredPointsLegendProfile- -# PlotsPlotly() in particular re-enables the detected and censored entries, so a -# layout flag on its own leaves traces marked visible underneath it. -plot_none = suppressWarnings( - dataProcessPlots(QuantData, type = "ProfilePlot", +# Test 11: text.angle no longer suppresses the Plotly label layout ----------- +# ggplotly() does not carry geom_text() rotation through, so a rotated Plotly +# plot is drawn horizontally and has exactly the crowding problem the layout +# exists to solve. The layout therefore runs whatever text.angle says, and the +# untruncated name stays on hover. + +QuantDataLong = QuantData +long_group = function(x) factor(paste0("Study_Tissue_Timepoint_", x)) +QuantDataLong$FeatureLevelData$GROUP = long_group(QuantDataLong$FeatureLevelData$GROUP) +QuantDataLong$ProteinLevelData$GROUP = long_group(QuantDataLong$ProteinLevelData$GROUP) + +condition_label_trace = function(text.angle) { + plot = suppressWarnings( + dataProcessPlots(QuantDataLong, type = "ProfilePlot", + which.Protein = protein_name, summaryPlot = FALSE, + address = FALSE, isPlotly = TRUE, + text.angle = text.angle) + )[[1]] + traces = plotly::plotly_build(plot)$x$data + Filter(function(trace) identical(trace$mode, "text"), traces)[[1]] +} + +for (angle in c(0, 90)) { + trace = condition_label_trace(angle) + expect_true(all(nchar(trace$text) < nchar(trace$hovertext))) + expect_true(all(grepl("^Study_Tissue_Timepoint_", trace$hovertext))) +} + +# Test 12: the ggplot2/PDF path keeps the full names and honours text.angle -- +# Nothing is laid out there, so condition.layout is NULL. This exercises that +# branch: the labels fall back to the full condition names and no headroom is +# added. + +tmp_dir3 = tempfile("msstats_dataprocessplots_pdf_") +dir.create(tmp_dir3) +expect_silent(suppressWarnings( + dataProcessPlots(QuantDataLong, type = "ProfilePlot", which.Protein = protein_name, summaryPlot = FALSE, - address = FALSE, isPlotly = TRUE, - legend.position = "none") -)[[1]] -built_none = plotly::plotly_build(plot_none) -expect_false(built_none$x$layout$showlegend) -expect_true(all(!vapply(built_none$x$data, - function(trace) isTRUE(trace$showlegend), logical(1)))) - -# Test 12: an undocumented legend.position is rejected rather than ignored ---- -expect_error( - dataProcessPlots(QuantData, type = "ProfilePlot", - which.Protein = protein_name, address = FALSE, - isPlotly = TRUE, legend.position = "middle") -) + address = paste0(tmp_dir3, "/"), text.angle = 90) +)) +expect_true(any(grepl("ProfilePlot.*\\.pdf$", list.files(tmp_dir3)))) +unlink(tmp_dir3, recursive = TRUE) + +# Test 13: there is no separate Plotly width argument ------------------------ +# The Plotly canvas width is an internal constant, not something the caller +# sizes; width is the PDF page. + +expect_false("width.plotly" %in% names(formals(dataProcessPlots))) + +# Test 14: the saved HTML container is sized to the plot it holds ------------ +# Regression test. The container was pinned at 800px while the widget inside it +# was 1400px wide, so the right-hand side of every saved plot -- which is where +# the feature legend is mounted -- fell outside the box. + +tmp_dir4 = tempfile("msstats_dataprocessplots_html_") +dir.create(tmp_dir4) +invisible(capture.output(suppressWarnings( + dataProcessPlots(QuantData, type = "ProfilePlot", which.Protein = protein_name, + summaryPlot = FALSE, address = paste0(tmp_dir4, "/"), + isPlotly = TRUE) +))) +zip_path = list.files(tmp_dir4, pattern = "\\.zip$", full.names = TRUE)[1] +unzip(zip_path, exdir = file.path(tmp_dir4, "unzipped")) +html_path = list.files(file.path(tmp_dir4, "unzipped"), pattern = "\\.html$", + full.names = TRUE, recursive = TRUE)[1] +html = paste(readLines(html_path, warn = FALSE), collapse = "\n") + +# The container div spaces its declarations, the widget div does not, so the +# two are told apart by the space after the semicolon. +px = function(pattern) { + as.integer(sub("^width:([0-9]+)px.*", "\\1", + regmatches(html, regexpr(pattern, html)))) +} +container_width = px("width:[0-9]+px; height:[0-9]+px; margin") +widget_width = px("width:[0-9]+px;height:[0-9]+px") + +expect_equal(container_width, widget_width) +expect_true(container_width >= 1400L) +unlink(tmp_dir4, recursive = TRUE) diff --git a/inst/tinytest/test_utils_dataprocess_plots.R b/inst/tinytest/test_utils_dataprocess_plots.R index 9ed29039..5088133f 100644 --- a/inst/tinytest/test_utils_dataprocess_plots.R +++ b/inst/tinytest/test_utils_dataprocess_plots.R @@ -13,9 +13,9 @@ layout_labels = MSstats:::.layoutConditionLabels # Test .stripCommonAffix ---------------------------------------------------- # Test 1: the shared stem is removed and reported -result = strip(c("Cyno_Colon_Timepoint_0hr", "Cyno_Colon_Timepoint_12hrs")) +result = strip(c("Study_Tissue_Timepoint_0hr", "Study_Tissue_Timepoint_12hrs")) expect_equal(result$labels, c("0hr", "12hrs")) -expect_equal(result$prefix, "Cyno_Colon_Timepoint_") +expect_equal(result$prefix, "Study_Tissue_Timepoint_") # Test 2: names sharing nothing are left alone result = strip(c("Alpha", "Beta")) @@ -78,9 +78,10 @@ expect_equal(wrap(c("0hr", "12hrs"), 10), c("0hr", "12hrs")) expect_equal(wrap("aaaa_bbbb_cccc", 6), "aaaa_\nbbbb_\ncccc") # Test 16: a single token wider than the slot cannot be broken, so it is -# truncated to exactly the slot width +# shortened to exactly the slot width, keeping both ends. Head-only truncation +# would drop the tail, which is the part that tells two conditions apart. result = wrap("ABCDEFGHIJKLMNOP", 6) -expect_equal(result, "ABC...") +expect_equal(result, "A...OP") expect_equal(nchar(result), 6L) # Test 17: every wrapped line respects the limit @@ -90,29 +91,25 @@ expect_true(all(nchar(lines) <= 8L)) # Test .layoutConditionLabels ----------------------------------------------- short = c("1", "2", "3") -long = paste0("Cyno_Colon_Timepoint_", c("0hr", "12hrs", "168hrs")) +long = paste0("Study_Tissue_Timepoint_", c("0hr", "12hrs", "168hrs")) # Test 18: labels that already fit are returned unchanged, at the caller's font -# size, on one line, under the plain axis title. This is the path every dataset -# that renders correctly today takes. +# size, on one line. This is the path every dataset that renders correctly today +# takes. result = layout_labels(short, 1, 1400, 4) expect_equal(result$labels, short) expect_equal(result$size, 4) expect_equal(result$n_lines, 1L) -expect_equal(result$xaxis, "MS runs") -# Test 19: labels that do not fit are shortened, and the stem moves to the axis -# title so it is still reported +# Test 19: labels that do not fit are shortened. The x-axis title stays the +# standard "MS runs" whatever the layout does, so it is not asserted on here. result = layout_labels(long, 2, 800, 4) expect_equal(result$labels, c("0hr", "12hrs", "168hrs")) -expect_true(grepl("Cyno_Colon_Timepoint_", result$xaxis, fixed = TRUE)) -# Test 20: a non-zero text.angle is a deliberate choice by the caller, so the -# layout is left alone. Note that ggplotly() does not carry rotation through, -# which is why rotation is not used as a mitigation here. -result = layout_labels(long, 2, 800, 4, text.angle = 90) -expect_equal(result$labels, long) -expect_equal(result$xaxis, "MS runs") +# Test 20: the layout takes no text.angle. It is only ever computed for the +# Plotly output, which ggplotly() draws horizontally whatever the caller asked +# for, so rotation cannot be a mitigation and cannot suppress one either. +expect_false("text.angle" %in% names(formals(layout_labels))) # Test 21: a single condition cannot collide with anything result = layout_labels("OnlyOneVeryLongConditionName", 1, 400, 4) @@ -135,3 +132,75 @@ expect_equal(length(unique(result$labels)), length(no_stem)) result = layout_labels(c("alpha_beta_gamma", "delta_epsilon_zeta"), 1, 300, 4) expect_equal(result$n_lines, max(lengths(strsplit(result$labels, "\n", fixed = TRUE)))) + +# Test 25: wrapping stops at three lines however cramped the canvas gets. +# Uncapped, this fixture reached 4 lines at 900px and 8 at 400px. +long_condition_names = c( + "0hr_0hr_20240101_XX_Sample_ctrl_f1_merged", + "12hrs_12hrs_20240101_Sample_Tissue_12h_f1_merged", + "168hrs_168hrs_202401011_XX_Sample_168h_f1_merged", + "1hr_1hr_20240101_XX_Sample_1h_f1_merged", + "24hrs_24hrs_20240101_XX_Sample_24h_f1_merged", + "48hrs_48hrs_20240101_XX_Sample_48h_f1_merged", + "4hr_4hr_20240101_XX_Sample_4h_f1_merged", + "96hrs_96hrs_20240101_XX_Sample_96h_f1_merged") +for (canvas in c(1400, 900, 600, 400)) { + result = layout_labels(long_condition_names, 1, canvas, 4) + expect_true(result$n_lines <= 3L) +} + +# Test 26: and the conditions stay tellable apart at every one of those widths +for (canvas in c(1400, 900, 600, 400)) { + result = layout_labels(long_condition_names, 1, canvas, 4) + expect_equal(length(unique(result$labels)), length(long_condition_names)) +} + +# Test 27: names that differ only in their tail survive the fold onto the last +# line. Keeping the first three lines and dropping the rest would render these +# two conditions as the same string. +shared_head = c("Cohort_Baseline_Liver_Replicate_Alpha_Treated", + "Cohort_Baseline_Liver_Replicate_Alpha_Control") +expect_equal(length(unique(wrap(shared_head, 10))), 2L) + +# Test 28: when no amount of shortening keeps the conditions distinct, the full +# names are drawn instead. A crowded axis is recoverable; two conditions sharing +# one label is not. These share a stem with no separator to break on, so the +# wrapper alone collapses them below eight characters. +covariates = c("DiseaseGroupMale", "DiseaseGroupFemale") +expect_equal(length(unique(wrap(covariates, 8))), 1L) +for (canvas in c(800, 400, 200, 120)) { + result = layout_labels(covariates, 1, canvas, 4) + expect_equal(length(unique(result$labels)), 2L) +} + +# Covariate designs --------------------------------------------------------- +# "Condition_Gender" is a very common way to encode a covariate, and the +# condition half of the name must survive. + +covariate_design = c("Disease_Male", "Disease_Female", + "Control_Male", "Control_Female") + +# Test 29: no single stem is shared by every name here -- Disease_ and Control_ +# each cover only half -- so nothing is dropped. +expect_equal(strip(covariate_design)$prefix, "") +expect_equal(strip(covariate_design)$labels, covariate_design) + +# Test 30: and that holds through the whole layout at any canvas width. The +# conditions stay distinct and every label still names its condition, even at +# widths cramped enough to force wrapping and truncation. +for (canvas in c(1400, 800, 500, 300, 200)) { + result = layout_labels(covariate_design, 1, canvas, 4) + expect_equal(length(unique(result$labels)), 4L) + expect_true(all(grepl("^(Dis|Con)", result$labels))) +} + +# Test 31: a third factor does not make the strip loop over-consume. "Week1" is +# shared by every name but is not a leading token, so it stays put. +expect_equal(strip(paste0(covariate_design, "_Week1"))$prefix, "") + +# Test 32: when every condition genuinely does share a leading stem it is +# dropped, covariate or not. Only reachable once the labels no longer fit, and +# only in the Plotly output, where the hover still carries the full name. +two_level = c("Disease_Male", "Disease_Female") +expect_equal(layout_labels(two_level, 1, 1400, 4)$labels, two_level) +expect_equal(layout_labels(two_level, 1, 300, 4)$labels, c("Male", "Female")) diff --git a/man/dataProcessPlots.Rd b/man/dataProcessPlots.Rd index 06df3198..908a2602 100644 --- a/man/dataProcessPlots.Rd +++ b/man/dataProcessPlots.Rd @@ -27,9 +27,7 @@ dataProcessPlots( save_condition_plot_result = FALSE, remove_uninformative_feature_outlier = FALSE, address = "", - isPlotly = FALSE, - legend.position = "right", - width.plotly = 1400 + isPlotly = FALSE ) } \arguments{ @@ -72,7 +70,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. In Profile Plot and QC plot the rotation applies to the +ggplot2/PDF output only: \code{ggplotly()} does not carry the rotation of +the condition labels through, so \code{isPlotly = TRUE} draws them +horizontally and fits them to the available room instead. Condition plot +rotates its x-axis labels in both outputs.} \item{legend.size}{size of feature legend (transition-level or peptide-level) above graph in Profile Plot. Default is 7.} @@ -81,9 +83,13 @@ above graph in Profile Plot. Default is 7.} \item{dot.size.condition}{size of dots in condition plot. Default is 3.} -\item{width}{width of the saved file in pixels. Default is 800 pixels.} +\item{width}{width of the saved PDF file in pixels, converted at 72 pixels +per inch, so the default 800 is an 11.1 inch page. Does not affect the Plotly +output, whose canvas is fixed at the width MSstatsShiny reserves for these +plots.} -\item{height}{height of the saved file in pixels. Default is 600 pixels.} +\item{height}{height of the saved file in pixels. Default is 600 pixels. +Applies to both the PDF and the Plotly output.} \item{which.Protein}{Protein list to draw plots. List can be names of Proteins or order numbers of Proteins from levels(data$FeatureLevelData$PROTEIN). @@ -117,19 +123,6 @@ 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 the Plotly output of -Profile Plot and QC Plot: "right" (default), "left", "top", "bottom", or "none" -to hide it. Only affects \code{isPlotly = TRUE}; the ggplot2 (PDF) output keeps -its legend above the graph. Note that only the side placements ("right" and -"left") are scrollable in Plotly, which is what stops a protein with many -features from covering the plot. A horizontal legend grows instead of -scrolling, so "top" and "bottom" can still crowd the panel on feature-rich -proteins.} - -\item{width.plotly}{width in pixels of the Plotly output. Default is 1400, which -matches the container MSstatsShiny reserves for these plots. Only affects -\code{isPlotly = TRUE}; the PDF output is sized by \code{width}.} } \description{ To illustrate the quantitative data after data-preprocessing and diff --git a/man/dot-conditionSlotChars.Rd b/man/dot-conditionSlotChars.Rd deleted file mode 100644 index b58bfecf..00000000 --- a/man/dot-conditionSlotChars.Rd +++ /dev/null @@ -1,37 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils_dataprocess_plots.R -\name{.conditionSlotChars} -\alias{.conditionSlotChars} -\title{Number of characters that fit in one condition's slot} -\usage{ -.conditionSlotChars(n_conditions, n_facets, width, text.size) -} -\arguments{ -\item{n_conditions}{number of conditions} - -\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.} - -\item{width}{width of the canvas in pixels, read as CSS pixels at 96dpi} - -\item{text.size}{size of the condition labels} -} -\value{ -integer, at least 1 -} -\description{ -Conditions tile the panel evenly, so each name gets `panel_width / -n_conditions` of room no matter how many runs it covers -- which is why -crowding is a function of name length and condition count, and never of the -number of samples per condition. -} -\details{ -Width is estimated from `nchar` rather than 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 layout device-dependent and this -function untestable. 0.53 em per character is calibrated against -`graphics::strwidth()` and lands within ~7%. -} -\keyword{internal} diff --git a/man/dot-conditionXlab.Rd b/man/dot-conditionXlab.Rd deleted file mode 100644 index c1e77dd7..00000000 --- a/man/dot-conditionXlab.Rd +++ /dev/null @@ -1,21 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils_dataprocess_plots.R -\name{.conditionXlab} -\alias{.conditionXlab} -\alias{.conditionTextSize} -\title{Accessors for the condition label layout} -\usage{ -.conditionXlab(layout) - -.conditionTextSize(layout, text.size) -} -\arguments{ -\item{layout}{result of `.layoutConditionLabels()`, or NULL} - -\item{text.size}{size to fall back to} -} -\description{ -The builders are also called with `condition.layout = NULL` (nothing computed -a layout), in which case they fall back to the historical behaviour. -} -\keyword{internal} diff --git a/man/dot-layoutConditionLabels.Rd b/man/dot-layoutConditionLabels.Rd deleted file mode 100644 index 047764f7..00000000 --- a/man/dot-layoutConditionLabels.Rd +++ /dev/null @@ -1,34 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils_dataprocess_plots.R -\name{.layoutConditionLabels} -\alias{.layoutConditionLabels} -\title{Lay out condition labels so they do not overlap} -\usage{ -.layoutConditionLabels(names, n_facets, width, text.size, text.angle = 0) -} -\arguments{ -\item{names}{character, condition names in plotting order} - -\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.} - -\item{width}{width of the canvas in pixels, read as CSS pixels at 96dpi} - -\item{text.size}{size of the condition labels} - -\item{text.angle}{angle of the labels. A non-zero value is a deliberate choice -by the caller, so the layout is left alone. Note that rotation is not carried -through by `ggplotly()`, so it does not help the MSstatsShiny output.} -} -\value{ -list with `labels`, the `size` to draw them at, the `n_lines` they - occupy, and the `xaxis` title to use -} -\description{ -Applies the three mitigations in order of how much they cost the reader: -drop the shared stem, then shrink the font, then wrap. Each is a no-op when -the labels already fit, so a plot that renders correctly today is unchanged. -} -\keyword{internal} diff --git a/man/dot-makeConditionPlot.Rd b/man/dot-makeConditionPlot.Rd index dbc11a37..160ca277 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. In Profile Plot and QC plot the rotation applies to the +ggplot2/PDF output only: \code{ggplotly()} does not carry the rotation of +the condition labels through, so \code{isPlotly = TRUE} draws them +horizontally and fits them to the available room instead. Condition plot +rotates its x-axis labels in both outputs.} \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 deleted file mode 100644 index edd4f21b..00000000 --- a/man/dot-makeProfilePlot.Rd +++ /dev/null @@ -1,58 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils_dataprocess_plots.R -\name{.makeProfilePlot} -\alias{.makeProfilePlot} -\title{Create profile plot} -\usage{ -.makeProfilePlot( - 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, - condition.layout = NULL -) -} -\arguments{ -\item{input}{data.table} - -\item{is_censored}{TRUE if censored values were imputed} - -\item{featureName}{for "ProfilePlot" only, "Transition" (default) means -printing feature legend in transition-level; "Peptide" means printing feature -legend in peptide-level; "NA" means no feature legend printing.} - -\item{x.axis.size}{size of x-axis labeling for "Run" in Profile Plot and -QC Plot, and "Condition" in Condition Plot. Default is 10.} - -\item{y.axis.size}{size of y-axis labels. Default is 10.} - -\item{text.size}{size of labels represented each condition at the top of -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.} - -\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.} -} -\description{ -Create profile plot -} -\keyword{internal} diff --git a/man/dot-makeQCPlot.Rd b/man/dot-makeQCPlot.Rd deleted file mode 100644 index b3344c23..00000000 --- a/man/dot-makeQCPlot.Rd +++ /dev/null @@ -1,83 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils_dataprocess_plots.R -\name{.makeQCPlot} -\alias{.makeQCPlot} -\title{Make QC plot} -\usage{ -.makeQCPlot( - 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, - condition.layout = NULL -) -} -\arguments{ -\item{input}{data.table} - -\item{all_proteins}{character vector of protein names} - -\item{x.axis.size}{size of x-axis labeling for "Run" in Profile Plot and -QC Plot, and "Condition" in Condition Plot. Default is 10.} - -\item{y.axis.size}{size of y-axis labels. Default is 10.} - -\item{text.size}{size of labels represented each condition at the top of -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.} - -\item{legend.size}{size of feature legend (transition-level or peptide-level) -above graph in Profile Plot. Default is 7.} -} -\description{ -To illustrate the quantitative data after data-preprocessing and -quality control of MS runs, dataProcessPlots takes the quantitative data from -function (\code{\link{dataProcess}}) as input and automatically generate -three types of figures in pdf files as output : -(1) profile plot (specify "ProfilePlot" in option type), -to identify the potential sources of variation for each protein; -(2) quality control plot (specify "QCPlot" in option type), -to evaluate the systematic bias between MS runs; -(3) mean plot for conditions (specify "ConditionPlot" in option type), -to illustrate mean and variability of each condition per protein. -} -\details{ -\itemize{ -\item{Profile Plot : identify the potential sources of variation of each protein. QuantData$FeatureLevelData is used for plots. X-axis is run. Y-axis is log-intensities of transitions. Reference/endogenous signals are in the left/right panel. Line colors indicate peptides and line types indicate transitions. In summarization plots, gray dots and lines are the same as original profile plots with QuantData$FeatureLevelData. Dark dots and lines are for summarized intensities from QuantData$ProteinLevelData.} -\item{QC Plot : illustrate the systematic bias between MS runs. After normalization, the reference signals for all proteins should be stable across MS runs. QuantData$FeatureLevelData is used for plots. X-axis is run. Y-axis is log-intensities of transition. Reference/endogenous signals are in the left/right panel. The pdf file contains (1) QC plot for all proteins and (2) QC plots for each protein separately.} -\item{Condition Plot : illustrate the systematic difference between conditions. Summarized intensnties from QuantData$ProteinLevelData are used for plots. X-axis is condition. Y-axis is summarized log transformed intensity. If scale is TRUE, the levels of conditions is scaled according to its actual values at x-axis. Red points indicate the mean for each condition. If interval is "CI", blue error bars indicate the confidence interval with 0.95 significant level for each condition. If interval is "SD", blue error bars indicate the standard deviation for each condition.The interval is not related with model-based analysis.} -} -The input of this function is the quantitative data from function \code{\link{dataProcess}}. -} -\examples{ -# Consider quantitative data (i.e. QuantData) from a yeast study with ten time points of interests, -# three biological replicates, and no technical replicates which is a time-course experiment. -# The goal is to provide pre-analysis visualization by automatically generate two types of figures -# in two separate pdf files. -# Protein IDHC (gene name IDP2) is differentially expressed in time point 1 and time point 7, -# whereas, Protein PMG2 (gene name GPM2) is not. - -QuantData<-dataProcess(SRMRawData, use_log_file = FALSE) -head(QuantData$FeatureLevelData) -# Profile plot -dataProcessPlots(data=QuantData,type="ProfilePlot") -# Quality control plot -dataProcessPlots(data=QuantData,type="QCPlot") -# Quantification plot for conditions -dataProcessPlots(data=QuantData,type="ConditionPlot") - -} -\keyword{internal} diff --git a/man/dot-makeSummaryProfilePlot.Rd b/man/dot-makeSummaryProfilePlot.Rd deleted file mode 100644 index 72c08af3..00000000 --- a/man/dot-makeSummaryProfilePlot.Rd +++ /dev/null @@ -1,50 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils_dataprocess_plots.R -\name{.makeSummaryProfilePlot} -\alias{.makeSummaryProfilePlot} -\title{Make summary profile plot} -\usage{ -.makeSummaryProfilePlot( - 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, - condition.layout = NULL -) -} -\arguments{ -\item{input}{data.table} - -\item{is_censored}{TRUE if censored values were imputed} - -\item{x.axis.size}{size of x-axis labeling for "Run" in Profile Plot and -QC Plot, and "Condition" in Condition Plot. Default is 10.} - -\item{y.axis.size}{size of y-axis labels. Default is 10.} - -\item{text.size}{size of labels represented each condition at the top of -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.} - -\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.} -} -\description{ -Make summary profile plot -} -\keyword{internal} diff --git a/man/dot-stripCommonAffix.Rd b/man/dot-stripCommonAffix.Rd deleted file mode 100644 index 47067d9f..00000000 --- a/man/dot-stripCommonAffix.Rd +++ /dev/null @@ -1,23 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils_dataprocess_plots.R -\name{.stripCommonAffix} -\alias{.stripCommonAffix} -\title{Drop the prefix that every condition name shares} -\usage{ -.stripCommonAffix(names) -} -\arguments{ -\item{names}{character, condition names in plotting order} -} -\value{ -list with `labels` (shortened) and `prefix` (what was removed, "" when - nothing is shared) -} -\description{ -Condition names in real designs are usually built from a common stem plus a -distinguishing tail -- "Cyno_Colon_Timepoint_0hr", "Cyno_Colon_Timepoint_12hrs". -Only the tail identifies the block, but the shared stem is what consumes the -horizontal room, so it is dropped from the in-panel label and reported once in -the x-axis title instead. No information is lost from the static image. -} -\keyword{internal} diff --git a/man/dot-wrapConditionLabels.Rd b/man/dot-wrapConditionLabels.Rd deleted file mode 100644 index b65bfb94..00000000 --- a/man/dot-wrapConditionLabels.Rd +++ /dev/null @@ -1,24 +0,0 @@ -% 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 onto several lines so they fit their slot} -\usage{ -.wrapConditionLabels(names, chars) -} -\arguments{ -\item{names}{character, condition names} - -\item{chars}{maximum characters per line} -} -\value{ -character, `names` unchanged when they all already fit -} -\description{ -Used only for what `.stripCommonAffix()` and shrinking the font cannot fix. -`strwrap()` breaks only at whitespace and condition names are usually -underscore-delimited, so separators are turned into break opportunities here. -A single token wider than the slot cannot be broken and is truncated; the -untruncated name stays available in the Plotly hover. -} -\keyword{internal}