diff --git a/CHANGELOG.md b/CHANGELOG.md index d33fb36..5540481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### Updates +- `DensityScatterPlot` can now draw `rectangle` or `quadrant` gates by selecting the appropriate `gate_type` argument. Additionally, gate annotation aesthetics can now be customized using `annotation_params`. ## [0.12.1] - 2025-01-21 diff --git a/NAMESPACE b/NAMESPACE index 545f6c5..f827129 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -212,7 +212,9 @@ importFrom(dplyr,group_by) importFrom(dplyr,group_by_at) importFrom(dplyr,group_data) importFrom(dplyr,group_keys) +importFrom(dplyr,group_modify) importFrom(dplyr,group_split) +importFrom(dplyr,inner_join) importFrom(dplyr,left_join) importFrom(dplyr,mutate) importFrom(dplyr,mutate_if) @@ -222,6 +224,7 @@ importFrom(dplyr,pull) importFrom(dplyr,relocate) importFrom(dplyr,rename) importFrom(dplyr,row_number) +importFrom(dplyr,rowwise) importFrom(dplyr,select) importFrom(dplyr,summarise) importFrom(dplyr,summarize) @@ -248,6 +251,7 @@ importFrom(ggplot2,ggplot) importFrom(ggplot2,ggplot_build) importFrom(ggplot2,ggplot_gtable) importFrom(ggplot2,labs) +importFrom(ggplot2,layer_scales) importFrom(ggplot2,position_dodge) importFrom(ggplot2,position_stack) importFrom(ggplot2,scale_color_gradientn) @@ -328,6 +332,7 @@ importFrom(tidygraph,"%N>%") importFrom(tidygraph,as_tbl_graph) importFrom(tidygraph,bind_edges) importFrom(tidygraph,to_components) +importFrom(tidyr,crossing) importFrom(tidyr,pivot_longer) importFrom(tidyr,pivot_wider) importFrom(tidyr,unite) diff --git a/R/density_scatter_plot.R b/R/density_scatter_plot.R index 5b589b5..62ee91a 100644 --- a/R/density_scatter_plot.R +++ b/R/density_scatter_plot.R @@ -1,45 +1,3 @@ -#' Get density of points in 2 dimensions. -#' -#' Calculate the kernel density of points in 2 dimensions. -#' -#' @param x A numeric vector. -#' @param y A numeric vector. -#' @param n Create a square n by n grid to compute density. -#' @param ... Additional arguments to pass to 'MASS::kde2d'. -#' @return The density within each square. -#' -#' @noRd -#' -#' @examples -#' -#' library(pixelatorR) -#' -#' x <- rnorm(1000) -#' y <- rnorm(1000) -#' -#' get_density(x, y, n = 100) -#' -.get2Ddensity <- function( - x, - y, - n = 500, - ... -) { - # Check that MASS is installed - expect_MASS() - - dens_grid <- - MASS::kde2d(x, y, n = n, ...) - - dens <- - dens_grid$z[cbind( - findInterval(x, dens_grid$x), - findInterval(y, dens_grid$y) - )] - - return(dens) -} - #' Create a density scatter / pseudocolor plot. #' #' Create a density scatter plot, also known as a pseudocolor plot, of two markers from a Seurat object. The points in @@ -48,29 +6,31 @@ #' #' #' @param object A Seurat object. -#' @param marker1 Marker to plot along the x-axis. -#' @param marker2 Marker to plot along the y-axis. -#' @param facet_vars Variables to facet the plot by. If NULL, no faceting is done. If a character vector with 1 element, -#' the plot is faceted by rows. If a character vector with 2 elements, the plot is faceted by rows and -#' columns. -#' @param plot_gate A data.frame with columns 'xmin', 'xmax', 'ymin', 'ymax' to plot a gate. This data.frame can also -#' contain the variables in 'facet_vars' to plot different gates in different facets. -#' @param scale_density Scale the density to the maximum density per facet. -#' @param margin_density Add marginal density plots. Only supported when 'facet_vars' is NULL. -#' @param coord_fixed Fix the aspect ratio of the plot. Only supported when 'margin_density' is FALSE. -#' @param pt_size Size of the points. -#' @param alpha Transparency of the points. -#' @param layer Name of layer to plot. If NULL, the default layer is used. -#' @param grid_n Number of grid points to calculate the density. -#' @param colors Colors to use for the density plot. If NULL, the 'viridis' "turbo" palette is used. -#' @param ... Additional arguments to pass to 'MASS::kde2d'. +#' @param marker1 Name of first marker to plot. +#' @param marker2 Name of second marker to plot. +#' @param facet_vars Optional character vector of length 1 or 2 specifying variables to facet by. +#' @param plot_gate Optional data frame containing gate parameters. For `gate_type = "rectangle"`, must contain columns +#' 'xmin', 'xmax', 'ymin', 'ymax' defining the rectangular gate boundaries. For gate_type = "quadrant", must contain +#' columns 'x' and 'y' defining the position of the quadrant lines. The data frame can also contain the variables +#' specified in 'facet_vars' to plot different gates in different facets. +#' @param gate_type Optional string specifying the gate type. Must be either "rectangle" or "quadrant". +#' Required if `plot_gate` is provided. +#' @param grid_n Number of grid points in each direction to compute density. +#' @param scale_density Whether to scale the density within each facet. +#' @param margin_density Whether to plot density plots in the margins. +#' @param pt_size Point size. +#' @param alpha Point transparency. +#' @param layer Optional string specifying a layer to plot. +#' @param coord_fixed Whether to use fixed coordinate ratio. +#' @param annotation_params Optional list of parameters to pass to `geom_text` for gate annotations. Common parameters +#' include color (text color), vjust (vertical justification), hjust (horizontal justification), and size (text size). +#' @param colors Optional character vector of colors to use for the color scale. +#' @param ... Additional arguments to pass to `MASS::kde2d`. #' #' @return A ggplot object. #' -#' @import rlang #' #' @examples -#' #' library(pixelatorR) #' library(Seurat) #' @@ -116,9 +76,45 @@ #' marker2 = "Feature2", #' facet_vars = "sample", #' plot_gate = plot_gate, +#' gate_type = "rectangle", #' layer = "counts" #' ) #' +#' +#' # Create a Seurat object with random data +#' counts <- matrix(rpois(200, 5), nrow = 10) +#' rownames(counts) <- paste0("Feature", 1:10) +#' colnames(counts) <- paste0("Cell", 1:20) +#' object <- CreateSeuratObject(counts = counts) +#' +#' # Create a basic density scatter plot +#' DensityScatterPlot( +#' object, +#' marker1 = "Feature1", +#' marker2 = "Feature2" +#' ) +#' +#' # Create a density scatter plot with a quadrant gate +#' # Define quadrant gate parameters +#' quad_gate <- data.frame( +#' x = 2, # x-coordinate for vertical line +#' y = 3 # y-coordinate for horizontal line +#' ) +#' +#' # Plot with quadrant gate and custom annotations +#' DensityScatterPlot( +#' object, +#' marker1 = "Feature1", +#' marker2 = "Feature2", +#' plot_gate = quad_gate, +#' gate_type = "quadrant", +#' annotation_params = list( +#' color = "darkblue", +#' size = 4, +#' fontface = "bold" +#' ) +#' ) +#' #' @export #' DensityScatterPlot <- function( @@ -127,273 +123,553 @@ DensityScatterPlot <- function( marker2, facet_vars = NULL, plot_gate = NULL, + gate_type = c("rectangle", "quadrant"), + grid_n = 500, scale_density = TRUE, - margin_density = FALSE, - coord_fixed = TRUE, + margin_density = TRUE, pt_size = 1, alpha = 1, layer = NULL, - grid_n = 500, + coord_fixed = TRUE, + annotation_params = NULL, colors = NULL, ... ) { - # Validate input parameters - assert_vector(facet_vars, type = "character", n = 1, allow_null = TRUE) - assert_max_length(facet_vars, n = 2, allow_null = TRUE) - for (facet_var in facet_vars) { - assert_col_in_data(facet_var, object[[]], allow_null = TRUE) + # Validate inputs + validated_inputs <- .validateDensityInputs( + object, + marker1, + marker2, + facet_vars, + plot_gate, + gate_type, + margin_density, + coord_fixed, + grid_n, + scale_density, + pt_size, + alpha, + layer, + annotation_params + ) + + # Prepare data + plot_data <- .prepareDensityData( + object, + marker1, + marker2, + facet_vars, + grid_n, + scale_density, + layer, + ... + ) + + # Create base plot + gg <- .createBasePlot( + plot_data, + pt_size, + alpha, + colors, + validated_inputs$coord_fixed + ) + + # Add faceting + if (!is.null(facet_vars)) { + if (length(facet_vars) == 1) { + gg <- gg + facet_grid(rows = vars(!!sym(facet_vars))) + } else { + gg <- gg + + facet_grid( + rows = vars(!!sym(facet_vars[1])), + cols = vars(!!sym(facet_vars[2])) + ) + } } - assert_single_value(marker1, type = "string") - assert_single_value(marker2, type = "string") - assert_x_in_y(marker1, rownames(object)) - assert_x_in_y(marker2, rownames(object)) - assert_single_value(grid_n, type = "integer") - assert_single_value(scale_density, type = "bool") - assert_single_value(margin_density, type = "bool") - assert_single_value(pt_size, type = "numeric") - assert_single_value(alpha, type = "numeric") - assert_single_value(layer, type = "string", allow_null = TRUE) - assert_single_value(coord_fixed, type = "bool") - assert_class(plot_gate, "data.frame", allow_null = TRUE) + + # Add gates if (!is.null(plot_gate)) { - assert_x_in_y(c("xmin", "xmax", "ymin", "ymax"), colnames(plot_gate), allow_null = TRUE) + gg <- .addGate( + gg = gg, + plot_gate = plot_gate, + gate_type = gate_type, + facet_vars = facet_vars, + annotation_params = annotation_params + ) + } + + # Add marginal density + if (validated_inputs$margin_density) { + gg <- .addMarginalDensity(gg, plot_data) + } + + return(gg) +} + +#' Validate inputs for density scatter plot +#' +#' @param object A Seurat object +#' @param marker1 Name of first marker +#' @param marker2 Name of second marker +#' @param facet_vars Variables to use for faceting +#' @param plot_gate Gate information for plotting +#' @param gate_type Type of gate ("rectangle" or "quadrant") +#' @param margin_density Whether to add marginal density plots +#' @param coord_fixed Whether to use fixed coordinate ratio +#' @param grid_n Number of grid points for density estimation +#' @param scale_density Whether to scale density values +#' @param pt_size Point size for plotting +#' @param alpha Point transparency +#' @param layer Layer to fetch data from +#' @param annotation_params Parameters for gate annotations +#' @param call Environment to use for the error call +#' @return List with validated margin_density and coord_fixed values +#' +#' @noRd +#' +.validateDensityInputs <- function( + object, + marker1, + marker2, + facet_vars, + plot_gate, + gate_type = NULL, + margin_density, + coord_fixed, + grid_n, + scale_density, + pt_size, + alpha, + layer = NULL, + annotation_params = NULL, + call = caller_env() +) { + # Basic object validation + assert_class(object, "Seurat", call = call) + + # Marker validation + assert_single_value(marker1, type = "string", call = call) + assert_single_value(marker2, type = "string", call = call) + assert_x_in_y(marker1, rownames(object), call = call) + assert_x_in_y(marker2, rownames(object), call = call) + + # Numeric parameter validation + assert_single_value(grid_n, type = "integer", call = call) + assert_single_value(pt_size, type = "numeric", call = call) + assert_single_value(alpha, type = "numeric", call = call) + + # Boolean parameter validation + assert_single_value(scale_density, type = "bool", call = call) + assert_single_value(margin_density, type = "bool", call = call) + assert_single_value(coord_fixed, type = "bool", call = call) + + # Layer validation + assert_single_value(layer, type = "string", allow_null = TRUE, call = call) + + # Facet variable checks + if (!is.null(facet_vars)) { + assert_vector( + facet_vars, + type = "character", + n = 1, + allow_null = TRUE, + call = call + ) + assert_max_length(facet_vars, n = 2, allow_null = TRUE, call = call) + + # Check each facet variable exists in the data + for (facet_var in facet_vars) { + assert_col_in_data(facet_var, object[[]], allow_null = TRUE, call = call) + } + } + + # Gate and annotation validation + if (!is.null(annotation_params)) { + assert_class(annotation_params, "list", call = call) } - assert_class(object, "Seurat") if (!is.null(plot_gate)) { - check <- !names(plot_gate) %in% c("xmin", "xmax", "ymin", "ymax", facet_vars) + assert_class(plot_gate, c("data.frame", "tbl_df"), call = call) + + if (is.null(gate_type)) { + cli::cli_abort( + "{.code gate_type} must be specified when {.code plot_gate} is provided" + ) + } + + if (!gate_type[1] %in% c("rectangle", "quadrant")) { + cli::cli_abort(c( + "Invalid gate type provided.", + "i" = "'gate_type' must be one of: {.code rectangle}, {.code quadrant}", + "x" = "You provided: {.val {gate_type[1]}}" + )) + } + + gate_type <- match.arg(gate_type, choices = c("rectangle", "quadrant")) + + # Validate required columns based on gate type + if (gate_type == "rectangle") { + assert_col_in_data("xmin", plot_gate, call = call) + assert_col_in_data("xmax", plot_gate, call = call) + assert_col_in_data("ymin", plot_gate, call = call) + assert_col_in_data("ymax", plot_gate, call = call) + } else { + assert_col_in_data("x", plot_gate, call = call) + assert_col_in_data("y", plot_gate, call = call) + } + + # Check for invalid columns in plot_gate + allowed_cols <- switch( + gate_type, + "rectangle" = c("xmin", "xmax", "ymin", "ymax"), + "quadrant" = c("x", "y") + ) + check <- !names(plot_gate) %in% c(allowed_cols, facet_vars) if (sum(check) > 0) { cli::cli_abort( c( - "i" = "'plot_gate' can't contain facetting variables that are not in {.var facet_vars}", - "x" = "The following variables are not in {.var facet_vars}: ", + "i" = "{.code plot_gate} can only contain gate-specific columns and faceting variables", + "x" = "The following columns are not allowed: ", " " = "{.val {names(plot_gate)[check]}}" ) ) } } - if (!(is.null(facet_vars) || !isTRUE(margin_density))) { - cli::cli_abort( - c( - "i" = "{.var margin_density=TRUE} is not supported when {.var facet_vars} is not {.cls NULL}", - "x" = "You've provided {.var margin_density={margin_density}} and {.var facet_vars=NULL}" - ) + # Compatibility checks + if (!is.null(facet_vars) && isTRUE(margin_density)) { + cli::cli_alert_warning( + "Marginal density ({.code margin_density = TRUE}) is not supported with faceting. + Setting {.code margin_density = FALSE}" ) + margin_density <- FALSE } if (isTRUE(coord_fixed) && isTRUE(margin_density)) { - warn("Fixed coordinates ('coord_fixed' = TRUE) is not supported when 'margin_density' is TRUE") + cli::cli_alert_warning( + "Setting {.code coord_fixed = FALSE} for compatibility with {.code margin_density = TRUE}" + ) + coord_fixed <- FALSE } - # Get data - plot_data <- - FetchData(object, - vars = c(facet_vars, marker1, marker2), - layer = layer - ) + return(list( + margin_density = margin_density, + coord_fixed = coord_fixed + )) +} + +#' Get density of points in 2 dimensions. +#' +#' Calculate the kernel density of points in 2 dimensions. +#' +#' @param x A numeric vector. +#' @param y A numeric vector. +#' @param n Create a square n by n grid to compute density. +#' @param ... Additional arguments to pass to 'MASS::kde2d'. +#' @return The density within each square. +#' +#' @noRd +#' +.get2Ddensity <- function(x, y, n = 500, ...) { + expect_MASS() + + dens_grid <- MASS::kde2d(x, y, n = n, ...) + x_idx <- findInterval(x, dens_grid$x) + y_idx <- findInterval(y, dens_grid$y) + return(dens_grid$z[cbind(x_idx, y_idx)]) +} - plot_data <- - plot_data %>% +#' Prepare data for density scatter plot +#' +#' @param object A Seurat object +#' @param marker1 Name of first marker +#' @param marker2 Name of second marker +#' @param facet_vars Variables to use for faceting +#' @param grid_n Size of density estimation grid +#' @param scale_density Whether to scale density values +#' @param layer Which layer to fetch data from +#' @param ... Additional arguments passed to .get2Ddensity +#' @return A data frame with density values +#' +#' @noRd +#' +.prepareDensityData <- function( + object, + marker1, + marker2, + facet_vars, + grid_n, + scale_density, + layer, + ... +) { + plot_data <- FetchData( + object, + vars = c(facet_vars, marker1, marker2), + layer = layer + ) %>% rename( marker1 = !!marker1, marker2 = !!marker2 - ) %>% - group_by_at(facet_vars) %>% + ) + + group_vars <- if (!is.null(facet_vars)) facet_vars else character(0) + + plot_data <- plot_data %>% + group_by(across(all_of(group_vars))) %>% mutate(dens = .get2Ddensity(marker1, marker2, n = grid_n, ...)) %>% ungroup() - if (isTRUE(scale_density)) { - plot_data <- - plot_data %>% - group_by_at(facet_vars) %>% - mutate(dens = dens / max(dens)) %>% + if (scale_density) { + plot_data <- plot_data %>% + group_by(across(all_of(group_vars))) %>% + mutate(dens = dens / max(dens, na.rm = TRUE)) %>% ungroup() } - # Set plot theme - if (is.null(facet_vars)) { - plot_theme <- - theme_bw() + - theme( - panel.grid = element_blank(), - panel.spacing = unit(0, "lines"), - plot.margin = unit(c(0, 0, 0, 0), "lines") - ) - } else { - plot_theme <- - theme_bw() + - theme(panel.grid = element_blank()) - } + return(plot_data) +} - plot_range <- - range(c(plot_data$marker1, plot_data$marker2)) +#' Create base ggplot for density scatter plot +#' +#' @param plot_data Data frame with plot data +#' @param pt_size Point size for scatter plot +#' @param alpha Alpha transparency for points +#' @param colors Vector of colors for density gradient +#' @param coord_fixed Whether to use fixed coordinate ratio +#' @return A ggplot object +#' +#' @noRd +#' +.createBasePlot <- function(plot_data, pt_size, alpha, colors, coord_fixed) { + gg <- ggplot( + plot_data, + aes(x = marker1, y = marker2, color = dens) + ) + + geom_point(size = pt_size, alpha = alpha, show.legend = FALSE) + + geom_hline(yintercept = 0, color = "gray50") + + geom_vline(xintercept = 0, color = "gray50") + + labs(x = "Marker1", y = "Marker2") + + theme_bw() + + theme(panel.grid = element_blank()) - if (!is.null(plot_gate)) { - plot_range <- - range(c( - plot_data$marker1, plot_data$marker2, - plot_gate$xmin, plot_gate$xmax, - plot_gate$ymin, plot_gate$ymax - )) + if (!is.null(colors)) { + gg <- gg + scale_color_gradientn(colors = colors) + } else { + gg <- gg + scale_color_viridis_c(option = "turbo") } - # Make plot - plot <- - plot_data %>% - ggplot(aes(marker1, marker2, color = dens)) + - geom_hline(yintercept = 0, color = "gray") + - geom_vline(xintercept = 0, color = "gray") + - geom_point( - size = pt_size, - alpha = alpha, - show.legend = FALSE - ) + - scale_x_continuous(limits = plot_range) + - scale_y_continuous(limits = plot_range) + - plot_theme + - labs( - x = marker1, - y = marker2 - ) - - if (isTRUE(coord_fixed) && isFALSE(margin_density)) { - plot <- plot + coord_fixed() + if (coord_fixed) { + gg <- gg + coord_fixed() } - if (!is.null(colors)) { - plot <- - plot + - scale_color_gradientn(colors = colors) - } else { - plot <- - plot + - scale_color_viridis_c(option = "H") - } + return(gg) +} - # Facet +#' Add gate annotation to density scatter plot +#' +#' @param gg Base ggplot object +#' @param plot_gate Gate information for plotting +#' @param gate_type Type of gate ("rectangle" or "quadrant") +#' @param facet_vars Variables used for faceting +#' @param annotation_params Parameters for gate annotation +#' @return A ggplot object with gate annotation +#' +#' @noRd +#' +.addGate <- function(gg, plot_gate, gate_type, facet_vars, annotation_params) { + # Join gate data with facet metadata if (!is.null(facet_vars)) { - if (length(facet_vars) == 1) { - plot <- - plot + - facet_grid(rows = vars(!!!syms(facet_vars))) - } - - if (length(facet_vars) == 2) { - plot <- - plot + - facet_grid( - rows = vars(!!!syms(facet_vars[1])), - cols = vars(!!!syms(facet_vars[2])) + join_vars <- intersect(facet_vars, names(plot_gate)) + if (length(join_vars) > 0) { + gate_data <- plot_gate %>% + inner_join( + unique(gg$data[, facet_vars, drop = FALSE]), + by = join_vars ) + } else { + gate_data <- plot_gate %>% + cross_join(unique(gg$data[, facet_vars, drop = FALSE])) } + } else { + gate_data <- plot_gate } + # Common gate validation + if (nrow(gate_data) == 0) { + cli::cli_abort("No matching facets found between gate data and plot data") + } - # Add gates - if (!is.null(plot_gate)) { - if (!is.null(facet_vars)) { - join_vars <- intersect(facet_vars, colnames(plot_gate)) - - if (length(join_vars) > 0) { - gate_label <- - plot_gate %>% - left_join(plot_data, - by = join_vars + # Group data by facet variables if they exist + plot_data <- gg$data + if (!is.null(facet_vars)) { + plot_data <- plot_data %>% + group_by(!!!syms(facet_vars)) + } + + # Calculate points inside gates for each facet group + gate_data <- gate_data %>% + group_by(!!!syms(facet_vars)) %>% + group_modify(function(group_data, group_keys) { + if (gate_type == "rectangle") { + group_data %>% + rowwise() %>% + mutate( + n_inside = sum( + plot_data$marker1 >= xmin & + plot_data$marker1 <= xmax & + plot_data$marker2 >= ymin & + plot_data$marker2 <= ymax + ), + total = nrow(plot_data) ) } else { - gate_label <- - plot_gate %>% - cross_join(plot_data) + group_data %>% + crossing( + quadrant = c("top_left", "top_right", "bottom_left", "bottom_right") + ) %>% + rowwise() %>% + mutate( + total = nrow(plot_data), + n_inside = sum( + if (quadrant == "top_left") { + plot_data$marker1 < x & plot_data$marker2 > y + } else if (quadrant == "top_right") { + plot_data$marker1 >= x & plot_data$marker2 > y + } else if (quadrant == "bottom_left") { + plot_data$marker1 < x & plot_data$marker2 <= y + } else { + plot_data$marker1 >= x & plot_data$marker2 <= y + } + ) + ) } - } else { - gate_label <- - plot_data %>% - cross_join(plot_gate) - } + }) %>% + ungroup() - gate_label <- - gate_label %>% - mutate(in_gate = marker1 > xmin & marker1 < xmax & marker2 > ymin & marker2 < ymax) %>% - group_by_at(c(facet_vars, "in_gate", "xmin", "xmax", "ymin", "ymax")) %>% - summarise( - n = n(), - .groups = "drop" - ) %>% - group_by_at(c(facet_vars, "xmin", "xmax", "ymin", "ymax")) %>% - mutate(p = 100 * n / sum(n)) %>% - ungroup() %>% - filter(in_gate) %>% + # Gate-type specific processing + if (gate_type == "rectangle") { + # Calculate annotation positions + gate_labels <- gate_data %>% mutate( - label = paste(round(p, 1), "%"), - x = (xmax + xmin) / 2, - y = ymax + x = (xmin + xmax) / 2, + y = ymax, + label = sprintf("%.1f%%", 100 * (n_inside / total)) ) - plot <- - plot + + # Create gate layers + gg <- gg + geom_rect( - data = plot_gate, + data = gate_data, aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax), inherit.aes = FALSE, - fill = "transparent", color = "black", + fill = NA, linetype = "dashed" - ) + - geom_text( - data = gate_label, - aes(x = x, y = y, label = label), - inherit.aes = FALSE, - color = "black", - vjust = 1, - size = 3 ) - } + } else { + # Calculate plot ranges + x_range <- layer_scales(gg)$x$range$range + y_range <- layer_scales(gg)$y$range$range - # Add marginal density - if (isTRUE(margin_density)) { - density_plot_x <- - plot_data %>% - ggplot(aes(marker1)) + - geom_density( - fill = "lightgray", - color = NA - ) + - scale_x_continuous(limits = plot_range) + - scale_y_continuous(expand = expansion(0)) + - theme_void() + - theme( - panel.spacing = unit(0, "lines"), - plot.margin = unit(c(0, 0, 0, 0), "lines") + # Create gate labels with positions + gate_labels <- gate_data %>% + mutate( + x_label = case_when( + quadrant %in% c("top_left", "bottom_left") ~ + x_range[1] + 0.05 * diff(x_range), + quadrant %in% c("top_right", "bottom_right") ~ + x_range[2] - 0.05 * diff(x_range) + ), + # Calculate consistent spacing based on plot range + label_spacing = 0.05 * diff(y_range), + y_label = case_when( + quadrant %in% c("top_left", "top_right") ~ y_range[2] - label_spacing, + quadrant %in% c("bottom_left", "bottom_right") ~ y - label_spacing + ), + hjust = ifelse(quadrant %in% c("top_left", "bottom_left"), 0, 1), + vjust = case_when(quadrant %in% c("top_left", "top_right") ~ 0), + label = sprintf("%.1f%%", 100 * (n_inside / total)) ) - density_plot_y <- - plot_data %>% - ggplot(aes(marker2)) + - geom_density( - fill = "lightgray", - color = NA - ) + - scale_x_continuous(limits = plot_range) + - scale_y_continuous(expand = expansion(0)) + - theme_void() + - theme( - panel.spacing = unit(0, "lines"), - plot.margin = unit(c(0, 0, 0, 0), "lines") + # Create gate layers + gg <- gg + + geom_vline( + data = unique(gate_data[, c("x", facet_vars)]), + aes(xintercept = x), + linetype = "dashed", + color = "black" ) + - coord_flip() - - plot <- - density_plot_x + - plot_spacer() + - plot + - density_plot_y + - plot_layout( - widths = c(5, 1), - heights = c(1, 5) + geom_hline( + data = unique(gate_data[, c("y", facet_vars)]), + aes(yintercept = y), + linetype = "dashed", + color = "black" ) } + # Add text annotations + annotation_args <- list( + data = gate_labels, + inherit.aes = FALSE, + show.legend = FALSE + ) + + # Gate-specific aesthetic mappings + if (gate_type == "rectangle") { + annotation_args$mapping <- aes(x = x, y = y, label = label) + default_params <- list(color = "black", size = 3, vjust = 1) + } else { + annotation_args$mapping <- aes( + x = x_label, + y = y_label, + label = label, + hjust = hjust, + vjust = vjust + ) + default_params <- list(color = "black", size = 3.5) + } + + # Merge user parameters with defaults + final_params <- utils::modifyList( + default_params, + annotation_params %||% list() + ) + + # Add annotations to plot + gg <- gg + + do.call(geom_text, c(annotation_args, final_params)) + + return(gg) +} + +#' Add marginal density plots +#' +#' @param gg Base ggplot object +#' @param plot_data Data frame with plot data +#' @return A ggplot object with marginal density plots +#' +#' @noRd +#' +.addMarginalDensity <- function(gg, plot_data) { + x_dens <- ggplot(plot_data, aes(x = marker1)) + + geom_density(fill = "grey70", color = NA) + + theme_void() + + scale_x_continuous(limits = range(plot_data$marker1)) + + y_dens <- ggplot(plot_data, aes(x = marker2)) + + geom_density(fill = "grey70", color = NA) + + theme_void() + + coord_flip() + + scale_x_continuous(limits = range(plot_data$marker2)) - return(plot) + wrap_plots( + x_dens, + plot_spacer(), + gg, + y_dens, + ncol = 2, + widths = c(4, 1), + heights = c(1, 4) + ) } diff --git a/R/pixelatorR-package.R b/R/pixelatorR-package.R index de3f75e..a0c3351 100644 --- a/R/pixelatorR-package.R +++ b/R/pixelatorR-package.R @@ -48,7 +48,9 @@ #' @importFrom dplyr group_by_at #' @importFrom dplyr group_data #' @importFrom dplyr group_keys +#' @importFrom dplyr group_modify #' @importFrom dplyr group_split +#' @importFrom dplyr inner_join #' @importFrom dplyr left_join #' @importFrom dplyr mutate #' @importFrom dplyr mutate_if @@ -58,6 +60,7 @@ #' @importFrom dplyr relocate #' @importFrom dplyr rename #' @importFrom dplyr row_number +#' @importFrom dplyr rowwise #' @importFrom dplyr select #' @importFrom dplyr summarise #' @importFrom dplyr summarize @@ -84,6 +87,7 @@ #' @importFrom ggplot2 ggplot_build #' @importFrom ggplot2 ggplot_gtable #' @importFrom ggplot2 labs +#' @importFrom ggplot2 layer_scales #' @importFrom ggplot2 position_dodge #' @importFrom ggplot2 position_stack #' @importFrom ggplot2 scale_color_gradientn @@ -180,6 +184,7 @@ #' @importFrom tidygraph as_tbl_graph #' @importFrom tidygraph bind_edges #' @importFrom tidygraph to_components +#' @importFrom tidyr crossing #' @importFrom tidyr pivot_longer #' @importFrom tidyr pivot_wider #' @importFrom tidyr unite diff --git a/man/DensityScatterPlot.Rd b/man/DensityScatterPlot.Rd index ba48ae7..1e37ba6 100644 --- a/man/DensityScatterPlot.Rd +++ b/man/DensityScatterPlot.Rd @@ -10,13 +10,15 @@ DensityScatterPlot( marker2, facet_vars = NULL, plot_gate = NULL, + gate_type = c("rectangle", "quadrant"), + grid_n = 500, scale_density = TRUE, - margin_density = FALSE, - coord_fixed = TRUE, + margin_density = TRUE, pt_size = 1, alpha = 1, layer = NULL, - grid_n = 500, + coord_fixed = TRUE, + annotation_params = NULL, colors = NULL, ... ) @@ -24,34 +26,40 @@ DensityScatterPlot( \arguments{ \item{object}{A Seurat object.} -\item{marker1}{Marker to plot along the x-axis.} +\item{marker1}{Name of first marker to plot.} + +\item{marker2}{Name of second marker to plot.} + +\item{facet_vars}{Optional character vector of length 1 or 2 specifying variables to facet by.} -\item{marker2}{Marker to plot along the y-axis.} +\item{plot_gate}{Optional data frame containing gate parameters. For \code{gate_type = "rectangle"}, must contain columns +'xmin', 'xmax', 'ymin', 'ymax' defining the rectangular gate boundaries. For gate_type = "quadrant", must contain +columns 'x' and 'y' defining the position of the quadrant lines. The data frame can also contain the variables +specified in 'facet_vars' to plot different gates in different facets.} -\item{facet_vars}{Variables to facet the plot by. If NULL, no faceting is done. If a character vector with 1 element, -the plot is faceted by rows. If a character vector with 2 elements, the plot is faceted by rows and -columns.} +\item{gate_type}{Optional string specifying the gate type. Must be either "rectangle" or "quadrant". +Required if \code{plot_gate} is provided.} -\item{plot_gate}{A data.frame with columns 'xmin', 'xmax', 'ymin', 'ymax' to plot a gate. This data.frame can also -contain the variables in 'facet_vars' to plot different gates in different facets.} +\item{grid_n}{Number of grid points in each direction to compute density.} -\item{scale_density}{Scale the density to the maximum density per facet.} +\item{scale_density}{Whether to scale the density within each facet.} -\item{margin_density}{Add marginal density plots. Only supported when 'facet_vars' is NULL.} +\item{margin_density}{Whether to plot density plots in the margins.} -\item{coord_fixed}{Fix the aspect ratio of the plot. Only supported when 'margin_density' is FALSE.} +\item{pt_size}{Point size.} -\item{pt_size}{Size of the points.} +\item{alpha}{Point transparency.} -\item{alpha}{Transparency of the points.} +\item{layer}{Optional string specifying a layer to plot.} -\item{layer}{Name of layer to plot. If NULL, the default layer is used.} +\item{coord_fixed}{Whether to use fixed coordinate ratio.} -\item{grid_n}{Number of grid points to calculate the density.} +\item{annotation_params}{Optional list of parameters to pass to \code{geom_text} for gate annotations. Common parameters +include color (text color), vjust (vertical justification), hjust (horizontal justification), and size (text size).} -\item{colors}{Colors to use for the density plot. If NULL, the 'viridis' "turbo" palette is used.} +\item{colors}{Optional character vector of colors to use for the color scale.} -\item{...}{Additional arguments to pass to 'MASS::kde2d'.} +\item{...}{Additional arguments to pass to \code{MASS::kde2d}.} } \value{ A ggplot object. @@ -62,7 +70,6 @@ the scatter plot are colored by the point density, determined by 2D kernel densi can be faceted by one or two variables and a gate can be plotted. } \examples{ - library(pixelatorR) library(Seurat) @@ -108,7 +115,43 @@ DensityScatterPlot(object, marker2 = "Feature2", facet_vars = "sample", plot_gate = plot_gate, + gate_type = "rectangle", layer = "counts" ) + +# Create a Seurat object with random data +counts <- matrix(rpois(200, 5), nrow = 10) +rownames(counts) <- paste0("Feature", 1:10) +colnames(counts) <- paste0("Cell", 1:20) +object <- CreateSeuratObject(counts = counts) + +# Create a basic density scatter plot +DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2" +) + +# Create a density scatter plot with a quadrant gate +# Define quadrant gate parameters +quad_gate <- data.frame( + x = 2, # x-coordinate for vertical line + y = 3 # y-coordinate for horizontal line +) + +# Plot with quadrant gate and custom annotations +DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = quad_gate, + gate_type = "quadrant", + annotation_params = list( + color = "darkblue", + size = 4, + fontface = "bold" + ) +) + } diff --git a/tests/testthat/test-DensityScatterPlot.R b/tests/testthat/test-DensityScatterPlot.R index aa5786a..b425520 100644 --- a/tests/testthat/test-DensityScatterPlot.R +++ b/tests/testthat/test-DensityScatterPlot.R @@ -6,66 +6,78 @@ for (assay_version in c("v3", "v5")) { # Create a dummy Seurat object set.seed(37) object <- - CreateSeuratObject(counts = matrix( - c( - rpois(100000, 40), - rpois(100000, 5) - )[sample(1:200000, 200000)], - nrow = 100, ncol = 2000, - dimnames = list( - paste0("Feature", 1:100), - paste0("Cell", 1:2000) - ) - ) %>% as("dgCMatrix")) %>% - AddMetaData(metadata = data.frame( - sample = rep(c("A", "B"), each = 1000), - sample_type = rep(c("Unstimulated", "Stimulated"), - each = 500, times = 2 - ), - row.names = paste0("Cell", 1:2000) - )) + CreateSeuratObject( + counts = matrix( + c( + rpois(100000, 40), + rpois(100000, 5) + )[sample(1:200000, 200000)], + nrow = 100, + ncol = 2000, + dimnames = list( + paste0("Feature", 1:100), + paste0("Cell", 1:2000) + ) + ) %>% + as("dgCMatrix") + ) %>% + AddMetaData( + metadata = data.frame( + sample = rep(c("A", "B"), each = 1000), + sample_type = rep( + c("Unstimulated", "Stimulated"), + each = 500, + times = 2 + ), + row.names = paste0("Cell", 1:2000) + ) + ) test_that("DensityScatterPlot works as expected", { # No facetting, no gating - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts" - )) + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + layer = "counts" + ) + ) # Other colors - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts", - colors = c("blue", "red") - )) + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + layer = "counts", + colors = c("blue", "red") + ) + ) # Marginal density - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts", - margin_density = T, - coord_fixed = F - )) - expect_warning(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts", - margin_density = T, - coord_fixed = T - )) - + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + layer = "counts", + margin_density = T, + coord_fixed = F + ) + ) # Facetting by two variables - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts", - facet_vars = c("sample", "sample_type"), - scale_density = F - )) + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + layer = "counts", + facet_vars = c("sample", "sample_type"), + scale_density = F + ) + ) # Single common gate plot_gate <- @@ -77,22 +89,29 @@ for (assay_version in c("v3", "v5")) { ) # No facetting - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts", - plot_gate = plot_gate - )) + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + layer = "counts", + plot_gate = plot_gate, + gate_type = "rectangle" + ) + ) # Facetting by one variable - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - facet_vars = "sample", - layer = "counts", - plot_gate = plot_gate - )) - + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + facet_vars = "sample", + layer = "counts", + plot_gate = plot_gate, + gate_type = "rectangle" + ) + ) # Gating by one variable plot_gate <- @@ -105,23 +124,30 @@ for (assay_version in c("v3", "v5")) { ) # Facetting by one variable - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - facet_vars = "sample", - layer = "counts", - plot_gate = plot_gate - )) + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + facet_vars = "sample", + layer = "counts", + plot_gate = plot_gate, + gate_type = "rectangle" + ) + ) # Facetting by two variables - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - facet_vars = c("sample", "sample_type"), - layer = "counts", - plot_gate = plot_gate - )) - + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + facet_vars = c("sample", "sample_type"), + layer = "counts", + plot_gate = plot_gate, + gate_type = "rectangle" + ) + ) # Multiple gates plot_gate <- @@ -132,15 +158,144 @@ for (assay_version in c("v3", "v5")) { ymax = c(70, 60, 18) ) - expect_no_error(DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - facet_vars = c("sample", "sample_type"), - layer = "counts", - plot_gate = plot_gate - )) + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + facet_vars = c("sample", "sample_type"), + layer = "counts", + plot_gate = plot_gate, + gate_type = "rectangle" + ) + ) + # Test plot_gate list format and quadrant gates + test_that("DensityScatterPlot handles new gate formats correctly", { + rect_gate <- tibble( + xmin = 20, + xmax = 70, + ymin = 20, + ymax = 50 + ) + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = rect_gate, + gate_type = "rectangle" + ) + ) + + # Quadrant gate + quad_gate <- tibble( + x = 30, + y = 40 + ) + + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = quad_gate, + gate_type = "quadrant" + ) + ) + + # Quadrant gate with faceting + quad_gate_faceted <- tibble( + x = c(30, 35), + y = c(40, 45), + sample = c("A", "B") + ) + + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + facet_vars = "sample", + plot_gate = quad_gate_faceted, + gate_type = "quadrant" + ) + ) + + # Test annotation_params + expect_no_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = quad_gate, + gate_type = "quadrant", + annotation_params = list( + color = "red", + size = 4, + fontface = "bold" + ) + ) + ) + }) + + test_that("DensityScatterPlot errors correctly for invalid inputs", { + # Invalid gate type + expect_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = tibble(x = 30, y = 40), + gate_type = "invalid_type" + ) + ) + + # Missing gate_type argument + expect_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = tibble(x = 30, y = 40) + ) + ) + + # Missing required columns for quadrant gate + expect_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = tibble(x = 30), + gate_type = "quadrant" + ) + ) + + # Missing required columns for rectangle gate + expect_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = tibble(xmin = 20, xmax = 70), + gate_type = "rectangle" + ) + ) + + # Invalid annotation_params (not a list) + expect_error( + DensityScatterPlot( + object, + marker1 = "Feature1", + marker2 = "Feature2", + plot_gate = quad_gate, + gate_type = "quadrant", + annotation_params = "invalid" + ) + ) + }) # Expected errors @@ -154,23 +309,16 @@ for (assay_version in c("v3", "v5")) { ) expect_error( - DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts", - facet_vars = "sample", - margin_density = T - ) - ) - expect_error( - DensityScatterPlot(object, + DensityScatterPlot( + object, marker1 = "FeatureNotHere", marker2 = "Feature2", layer = "counts" ) ) expect_error( - DensityScatterPlot(object, + DensityScatterPlot( + object, marker1 = "Feature1", marker2 = "Feature2", layer = "counts", @@ -178,7 +326,8 @@ for (assay_version in c("v3", "v5")) { ) ) expect_error( - DensityScatterPlot(object, + DensityScatterPlot( + object, marker1 = "Feature1", marker2 = "Feature2", layer = "counts", @@ -186,15 +335,18 @@ for (assay_version in c("v3", "v5")) { ) ) expect_error( - DensityScatterPlot(object, + DensityScatterPlot( + object, marker1 = "Feature1", marker2 = "Feature2", layer = "counts", - plot_gate = tibble(xmin = 20) + plot_gate = tibble(xmin = 20), + gate_type = "rectangle" ) ) expect_error( - DensityScatterPlot(object, + DensityScatterPlot( + object, marker1 = "Feature1", marker2 = "Feature2", facet_vars = NULL, @@ -202,16 +354,5 @@ for (assay_version in c("v3", "v5")) { plot_gate = plot_gate ) ) - - # Warning - expect_warning( - DensityScatterPlot(object, - marker1 = "Feature1", - marker2 = "Feature2", - layer = "counts", - margin_density = T - ), - regexp = "Fixed coordinates .* is not supported when 'margin_density' is TRUE" - ) }) }