OSCR

A single-cell and spatial atlas of early human olfactory development.

Code ↔ Paper

26 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 26 matches
  1. [1] § Methods › Data integration and dimensionality reduction ↔ R/figS4.Rmd, lines 81–138 · score 1.00 · IQCJ SCHIP1, COL13A1, COL19A1, KIF18B, OLFML2A, PDE1C
  2. [2] § Methods › Detection and removal of maternal erythroid contamination ↔ R/000-functions.R, lines 345–381 · score 1.00 · canonical hemoglobin genes, erythroid contamination, sex score thresholds, erythroid signal, DDX3Y, RPS4Y1
  3. [3] § Methods › Data integration and dimensionality reduction ↔ R/002-annotation.Rmd, lines 269–312 · score 0.99 · IQCJ SCHIP1, COL17A1, CycBasal, GnRH, TMEM119, ACAN
  4. [4] § Results › Single-nucleus atlas of the human fetal nasal compartment ↔ 05.scmusk.ipynb, lines 245–269 · score 0.83 · skeletal muscle, olfactory ensheathing, Schwann cells, neural crest, respiratory epithelial, pericytes
  5. [5] § Results › Single-nucleus atlas of the human fetal nasal compartment ↔ 05.scmusk.ipynb, lines 147–227 · score 0.77 · NOS1 neurons, cycling basal, Schwann cell, GnRH, precursors, GABA
  6. [6] § Methods › Bulk RNA-seq QC and reference for demultiplexing ↔ Python/figS1.ipynb, lines 61–195 · score 0.77 · minor allele frequency, Bulk RNA, missingness, Python, variants, genotype
  7. [7] § Results › Transcriptional regulators and gene networks shaping human OE development ↔ R/fig4.Rmd, lines 98–137 · score 0.76 · E2F1, ATOH1, EGR2, ID1, ID3, KLF15
  8. [8] § Results › Transcriptional regulators and gene networks shaping human OE development ↔ R/fig4.Rmd, lines 338–363 · score 0.73 · ARID1B, PHOX2A, BARX2, NEUROD2, HOXC8, NFKBIZ
  9. [9] § Results › Transcriptional regulators and gene networks shaping human OE development ↔ R/000-functions.R, lines 5364–5394 · score 0.71 · absolute regulatory, Source TFs, regulatory network, transcription factor, Node, scores
  10. [10] § Results › Olfactory receptor gene expression patterns during human OE development ↔ R/000-functions.R, lines 1781–1819 · score 0.70 · sparse single cell, cell identity, olfactory receptor, Dominance scores, iOSN, bin
  11. [11] § Results › Composition and development of the olfactory system in human fetuses ↔ R/000-functions.R, lines 2426–2489 · score 0.69 · Temporally regulated genes, dynamic range, FDR, Slingshot, smoothed, GAMs
  12. [12] § Methods › Genetic demultiplexing ↔ Python/figS1.ipynb, lines 61–195 · score 0.67 · donor genotype, bulk RNA, Allele, variants, SNPs, VCFs
  13. [13] § Methods › Statistics and reproducibility ↔ R/000-functions.R, lines 3491–3539 · score 0.65 · Kruskal Wallis, pairwise comparisons, Wilcoxon, dynamics, genes, cell
  14. [14] § Methods › Transcription factory activity ↔ R/fig4.Rmd, lines 58–96 · score 0.61 · decoupleR, TF activities, Seurat, assay, heatmaps, gene
  15. [15] § Results › Spatially resolved olfactory epithelium OR genes profiling confirms the 1 OR-1 OSN rule at single-cell resolution ↔ R/000-functions.R, lines 2042–2107 · score 0.59 · adjusted dominance score, expression magnitude, metric, iOSN, gene, cell
  16. [16] § Methods › Single-cell olfactory receptor (OR) dominance ↔ R/000-functions.R, lines 1781–1819 · score 0.59 · dominance scores quantified, zero, Seurat, binned, ORs, receptor
  17. [17] § Results › Single-nucleus atlas of the human fetal nasal compartment ↔ Python/figS1.ipynb, lines 509–584 · score 0.58 · bulk genotypes, Bulk RNA seq, missingness, Souporcell, Vireo, SNP
  18. [18] § Results › Single-nucleus atlas of the human fetal nasal compartment ↔ R/000-functions.R, lines 345–381 · score 0.58 · linked gene expression, discordant, erythroid, classifying, contamination, XIST
  19. [19] § Results › Single-nucleus atlas of the human fetal nasal compartment ↔ R/000-custom-colors.R, lines 2–44 · score 0.56 · early human, GnRH, GABA, GLUT, iOSN, NOS1
  20. [20] § Results › Composition and development of the olfactory system in human fetuses ↔ R/figS4.Rmd, lines 81–138 · score 0.56 · TOP2A, MYBL1, NNAT, NEUROG1, NEUROD1, HES6
  21. [21] § Methods › Transcription factory activity ↔ R/000-functions.R, lines 5364–5394 · score 0.54 · regulatory networks, igraph, ggraph, TFs, transcriptional
  22. [22] § Results › Composition and development of the olfactory system in human fetuses ↔ R/002-annotation.Rmd, lines 269–312 · score 0.54 · gnrh1, MUC5AC, iOSN, SOX2, RHBC, MV
  23. [23] § Results › Single-nucleus atlas of the human fetal nasal compartment ↔ R/000-functions.R, lines 173–206 · score 0.53 · quality control, detected genes, UMIs, mitochondrial, fractions, log
  24. [24] § Results › Composition and development of the olfactory system in human fetuses ↔ R/fig3.Rmd, lines 73–86 · score 0.52 · TOP2A, FOXN4, MYBL1, NNAT, NEUROG1, NEUROD1
  25. [25] § Methods › Trajectory inference and pseudotime ↔ R/000-functions.R, lines 2426–2489 · score 0.52 · highly variable genes, Slingshot, iOSN, lineage, pseudotime, OHBC
  26. [26] § Methods › Statistics and reproducibility ↔ 07.proportion.ipynb, lines 42–62 · score 0.51 · Mann Whitney, Wilcoxon, Kruskal, cell

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

R · 6,024 lines · 195 KB · GPL-3.0 · 11 matches

  1. # Customized R functions
  2. # Author= Yvon Mbouamboua ([email hidden])
  3. # Utility: %||%
  4. `%||%` <- function(a, b) if(!is.null(a)) a else b
  5. #' Remove duplicated cell barcodes within and/or across Seurat objects
  6. #'
  7. #' This function cleans duplicated cell barcodes in a list of Seurat objects.
  8. #' It can remove duplicates occurring **within each object**, **across objects**,
  9. #' or **both**. A detailed log is returned reporting how many cells were removed
  10. #' per object at each step.
  11. #'
  12. #' @param objs Named list of \code{Seurat} objects.
  13. #' @param mode Character string specifying which duplicates to remove:
  14. #' \itemize{
  15. #' \item \code{"within"} – remove duplicated barcodes within each object
  16. #' \item \code{"across"} – remove duplicated barcodes shared across objects
  17. #' \item \code{"both"} – remove both within- and across-object duplicates (default)
  18. #' }
  19. #' @param save Logical; whether to save cleaned Seurat objects to disk (default: \code{TRUE}).
  20. #' @param outdir Output directory where cleaned objects will be written.
  21. #' @param suffix Filename suffix for saved objects (default: \code{"_clean.rds"}).
  22. #' @param verbose Logical; whether to print a summary table to the console (default: \code{TRUE}).
  23. #'
  24. #' @return A list with two elements:
  25. #' \itemize{
  26. #' \item \code{objs} – cleaned list of Seurat objects
  27. #' \item \code{log} – data.frame summarizing barcode removal per object
  28. #' }
  29. #'
  30. #' @details
  31. #' Duplicate removal strategy:
  32. #' \enumerate{
  33. #' \item Remove duplicated cell barcodes *within* each Seurat object using
  34. #' \code{duplicated(colnames(object))}.
  35. #' \item Identify barcodes shared across multiple objects and remove them
  36. #' from all objects.
  37. #' }
  38. #'
  39. #' The function does not rename barcodes; duplicates are removed entirely.
  40. #' This behavior is appropriate for demultiplexed or merged datasets where
  41. #' barcode reuse indicates technical duplication.
  42. #'
  43. #' @examples
  44. #' \dontrun{
  45. #' res <- clean_seurat_barcodes(
  46. #' objs,
  47. #' mode = "both",
  48. #' outdir = "cleaned_objects"
  49. #' )
  50. #'
  51. #' objs_clean <- res$objs
  52. #' removal_log <- res$log
  53. #' }
  54. #'
  55. #' @seealso \code{\link[Seurat]{Seurat}}, \code{\link[base]{duplicated}}
  56. #'
  57. #' @export
  58. #'
  59. #' Clean duplicated cell barcodes in Seurat objects
  60. #'
  61. #' Removes duplicated cell barcodes within individual Seurat objects,
  62. #' across multiple Seurat objects, or both.
  63. #'
  64. #' @param objs A Seurat object or a named list of Seurat objects
  65. #' @param mode One of "within", "across", or "both"
  66. #' @param outdir Optional output directory to save cleaned objects
  67. #' @param verbose Logical; print summary messages
  68. #' @param save Logical; save data
  69. #' @return A list with cleaned Seurat objects and a log table
  70. #' @export
  71. #'
  72. clean_seurat_barcodes <- function(
  73. objs,
  74. mode = c("within", "across", "both"),
  75. outdir = NULL,
  76. save = FALSE,
  77. verbose = TRUE
  78. ) {
  79. mode <- match.arg(mode)
  80. # CRITICAL FIX — MUST BE FIRST
  81. if (inherits(objs, "Seurat")) {
  82. objs <- list(object = objs)
  83. }
  84. if (!is.list(objs)) {
  85. stop("`objs` must be a Seurat object or a list of Seurat objects.")
  86. }
  87. # Initialize log
  88. log <- data.frame(
  89. object = names(objs),
  90. removed_within = 0L,
  91. removed_across = 0L,
  92. kept_cells = 0L,
  93. stringsAsFactors = FALSE
  94. )
  95. # Remove duplicates WITHIN each object
  96. if (mode %in% c("within", "both")) {
  97. for (i in seq_along(objs)) {
  98. obj <- objs[[i]]
  99. cn <- colnames(obj)
  100. dup_within <- duplicated(cn)
  101. n_removed <- sum(dup_within)
  102. if (n_removed > 0) {
  103. obj <- obj[, !dup_within]
  104. }
  105. objs[[i]] <- obj
  106. log$removed_within[i] <- n_removed
  107. }
  108. }
  109. # Remove duplicates ACROSS objects
  110. if (mode %in% c("across", "both") && length(objs) > 1) {
  111. all_cells <- unlist(lapply(objs, colnames))
  112. dup_cells <- all_cells[duplicated(all_cells)]
  113. if (length(dup_cells) > 0) {
  114. dup_cells <- unique(dup_cells)
  115. for (i in seq_along(objs)) {
  116. obj <- objs[[i]]
  117. cn <- colnames(obj)
  118. to_remove <- cn %in% dup_cells
  119. n_removed <- sum(to_remove)
  120. if (n_removed > 0) {
  121. obj <- obj[, !to_remove]
  122. }
  123. objs[[i]] <- obj
  124. log$removed_across[i] <- n_removed
  125. }
  126. }
  127. }
  128. # Final counts
  129. for (i in seq_along(objs)) {
  130. log$kept_cells[i] <- ncol(objs[[i]])
  131. }
  132. # Save cleaned objects
  133. if (save) {
  134. if (!is.null(outdir)) {
  135. dir.create(outdir, recursive = TRUE, showWarnings = FALSE)
  136. for (nm in names(objs)) {
  137. saveRDS(objs[[nm]], file.path(outdir, paste0(nm, ".rds")))
  138. }
  139. }
  140. }
  141. if (verbose) {
  142. message("✔ Barcode cleaning complete")
  143. print(log)
  144. }
  145. return(list(
  146. objects = objs,
  147. log = log
  148. ))
  149. }
  150. #' Run Quality Control on Seurat Objects
  151. #'
  152. #' Performs automated cell filtering based on gene/UMI counts, mitochondrial
  153. #' and ribosomal content, dropout rate, and optional doublet detection.
  154. #' Works on single or multiple Seurat objects.
  155. #'
  156. #' @param x Seurat object or list of Seurat objects.
  157. #' @param min_feat Minimum number of detected genes.
  158. #' @param min_umi Minimum number of UMIs.
  159. #' @param mad_n Number of MADs for upper cutoffs (if method = "MAD").
  160. #' @param max_mito Maximum mitochondrial percentage.
  161. #' @param calc_ribo Logical; compute ribosomal gene percentage.
  162. #' @param max_ribo Maximum ribosomal percentage.
  163. #' @param rm_dbl Logical; perform doublet removal using scDblFinder.
  164. #' @param calc_drop Logical; compute dropout fraction.
  165. #' @param max_drop Maximum dropout fraction.
  166. #' @param method Thresholding method: "MAD", "fixed", or "none".
  167. #' @param fixed_thr Named list of fixed upper cutoffs.
  168. #' @param mito_pat Regex for mitochondrial genes.
  169. #' @param ribo_pat Regex for ribosomal genes.
  170. #' @param species "human" or "mouse" (sets default gene patterns).
  171. #' @param log_g2u Logical; filter by log10(genes/UMI).
  172. #' @param min_g2u Minimum genes/UMI ratio.
  173. #' @param sample_col Metadata column for sample ID.
  174. #' @param outdir Output directory.
  175. #' @param merge Logical; merge outputs into a single Seurat object.
  176. #'
  177. #' @return A filtered Seurat object, list, or merged object.
  178. #' @export
  179. #'
  180. #' @examples
  181. #' qc_data <- run_qc(seurat_list, calc_ribo = TRUE, rm_dbl = TRUE)
  182. #'
  183. run_qc <- function(
  184. x,
  185. min_feat = 200,
  186. min_umi = 500,
  187. mad_n = 5,
  188. max_mito = 5,
  189. calc_ribo = FALSE,
  190. max_ribo = 3,
  191. calc_drop = FALSE,
  192. max_drop = 0.95,
  193. rm_dbl = FALSE,
  194. method = c("MAD", "fixed", "none"),
  195. fixed_thr = list(max_feat = 6000, max_umi = 20000),
  196. mito_pat = NULL,
  197. ribo_pat = NULL,
  198. species = c("human", "mouse"),
  199. sample_col = "orig.ident",
  200. outdir = "QC",
  201. merge_output = TRUE,
  202. verbose = TRUE
  203. ) {
  204. library(Seurat)
  205. library(dplyr)
  206. library(Matrix)
  207. method <- match.arg(method)
  208. species <- match.arg(species)
  209. # Default gene patterns
  210. if (is.null(mito_pat)) mito_pat <- if (species == "human") "^MT-" else "^mt-"
  211. if (is.null(ribo_pat)) ribo_pat <- if (species == "human") "^RP[LS]" else "^Rp[ls]"
  212. dir.create(outdir, recursive = TRUE, showWarnings = FALSE)
  213. # Input validation
  214. if (inherits(x, "Seurat")) {
  215. obj_list <- list(x)
  216. single <- TRUE
  217. } else if (is.list(x) && all(sapply(x, inherits, "Seurat"))) {
  218. obj_list <- x
  219. single <- FALSE
  220. } else stop("Input must be a Seurat object or list of Seurat objects.")
  221. qc_sum <- data.frame()
  222. dbl_sum <- data.frame()
  223. if (verbose) cat("\n>>> Starting quality control for", length(obj_list), "sample(s)...\n")
  224. for (i in seq_along(obj_list)) {
  225. obj <- obj_list[[i]]
  226. sample <- if (sample_col %in% colnames([email hidden]))
  227. unique([email hidden][[sample_col]]) else paste0("Sample_", i)
  228. pre_cells <- ncol(obj)
  229. if (verbose) cat("\n────────────────────────────\n Sample:", sample, "| Cells:", pre_cells, "\n")
  230. # Compute QC metrics
  231. obj$percent_mito <- PercentageFeatureSet(obj, pattern = mito_pat)
  232. if (calc_ribo) obj$percent_ribo <- PercentageFeatureSet(obj, pattern = ribo_pat)
  233. if (calc_drop) {
  234. counts <- GetAssayData(obj, slot = "counts")
  235. obj$dropout <- Matrix::colSums(counts == 0) / nrow(counts)
  236. }
  237. # Compute thresholds
  238. if (method == "MAD") {
  239. max_feat <- median(obj$nFeature_RNA) + mad_n * mad(obj$nFeature_RNA)
  240. max_umi <- median(obj$nCount_RNA) + mad_n * mad(obj$nCount_RNA)
  241. } else if (method == "fixed") {
  242. max_feat <- fixed_thr$max_feat
  243. max_umi <- fixed_thr$max_umi
  244. } else {
  245. max_feat <- Inf; max_umi <- Inf
  246. }
  247. # Apply permissive filters
  248. filt <- subset(
  249. obj,
  250. nFeature_RNA > min_feat &
  251. nFeature_RNA < max_feat &
  252. nCount_RNA > min_umi &
  253. percent_mito < max_mito &
  254. (if (calc_ribo) percent_ribo < max_ribo else TRUE) &
  255. (if (calc_drop) dropout < max_drop else TRUE)
  256. # No max UMI cutoff — preserves high-depth nuclei
  257. )
  258. post_cells <- ncol(filt)
  259. pct_rm <- round((1 - post_cells / pre_cells) * 100, 2)
  260. if (verbose) cat(">>> Retained:", post_cells, "cells (", pct_rm, "% removed)\n")
  261. # Optional doublet filtering
  262. if (rm_dbl) {
  263. if (verbose) cat(">>> Running scDblFinder...\n")
  264. sce <- scDblFinder::scDblFinder(as.SingleCellExperiment(filt))
  265. filt$scDblFinder <- sce$scDblFinder.class
  266. filt <- subset(filt, scDblFinder == "singlet")
  267. dbl_sum <- rbind(dbl_sum, data.frame(Sample = sample, Doublets = sum(sce$scDblFinder.class == "doublet")))
  268. }
  269. # Summarize
  270. qc_sum <- rbind(qc_sum, data.frame(
  271. Sample = sample,
  272. Pre_Cells = pre_cells,
  273. Post_Cells = post_cells,
  274. Removed_Pct = pct_rm,
  275. min_feat, max_feat, min_umi,
  276. max_mito,
  277. Method = method, MADs = mad_n
  278. ))
  279. obj_list[[i]] <- filt
  280. }
  281. # Save summary
  282. write.csv(qc_sum, file.path(outdir, "QC_summary.csv"), row.names = FALSE)
  283. if (rm_dbl && nrow(dbl_sum) > 0)
  284. write.csv(dbl_sum, file.path(outdir, "Doublet_summary.csv"), row.names = FALSE)
  285. # Return object
  286. if (merge_output && length(obj_list) > 1) {
  287. merged <- merge(x = obj_list[[1]], y = obj_list[-1], merge.data = TRUE)
  288. if (verbose) cat("\n>>> Merged object returned.\n")
  289. if (verbose) cat("\n>>> Join leyers.\n")
  290. #merged <- JoinLayers(merged)
  291. #merged <- fix_seurat_matrix_names(merged)
  292. return(merged)
  293. } else if (single) {
  294. if (verbose) cat("\n>>> Returning single filtered object.\n")
  295. #obj_list[[1]] <- fix_seurat_matrix_names(obj_list[[1]])
  296. return(obj_list[[1]])
  297. } else {
  298. if (verbose) cat("\n>>> Returning list of filtered objects.\n")
  299. return(obj_list)
  300. }
  301. }
  302. #' Sex and Erythroid Contamination QC for Seurat Objects
  303. #'
  304. #' This function computes per-cell sex scores based on XIST and Y-linked gene expression,
  305. #' flags sex-discordant cells, calculates erythroid contamination using canonical hemoglobin genes,
  306. #' and filters cells that are both sex-discordant and above a specified erythroid threshold. Optional QC plots are produced.
  307. #'
  308. #' @param obj A Seurat object containing single-cell RNA-seq data.
  309. #' @param sample_col Character. Column name in `[email hidden]` corresponding to sample IDs. Default is "sample".
  310. #' @param y_genes Character vector. List of Y-chromosome genes to use for sex score. Default is c("UTY","RPS4Y1","ZFY","DDX3Y","KDM5D").
  311. #' @param xist_gene Character. Gene name for XIST. Default is "XIST".
  312. #' @param eps Numeric. Small pseudocount added to avoid division by zero. Default is 1e-6.
  313. #' @param female_thresh Numeric. Sex score threshold above which a cell is classified as female-like. Default is 1.
  314. #' @param male_thresh Numeric. Sex score threshold below which a cell is classified as male-like. Default is -1.
  315. #' @param eryth_genes Character vector. Canonical hemoglobin genes for erythroid contamination assessment. Default is c("HBB","HBA1","HBA2","HBE1","HBG1","HBG2","HBM").
  316. #' @param eryth_percentile Numeric. Percentile to define high erythroid signal for filtering (0-1). Default is 0.95.
  317. #' @param make_plots Logical. Whether to produce QC plots. Default is TRUE.
  318. #' @param plot_prefix Character. Prefix for saved QC plot filenames. Default is "qc_".
  319. #'
  320. #' @return A list with the following components:
  321. #' \item{object}{The input Seurat object with added metadata columns: sex_score, sex_call, eryth_sum, inferred_sex, sex_discordant.}
  322. #' \item{filtered}{A Seurat object filtered to remove cells that are both sex-discordant and above the erythroid threshold.}
  323. #' \item{summary}{A per-sample summary table including cell counts, fractions female/male/ambiguous, median sex score, fraction of cells with erythroid expression, and median erythroid sum.}
  324. #' \item{eryth_threshold}{Numeric value corresponding to the threshold for high erythroid signal (computed from `eryth_percentile`).}
  325. #' \item{n_removed}{Number of cells removed by filtering.}
  326. #'
  327. #' @examples
  328. #' \dontrun{
  329. #' qc <- sex_contamination_qc(obj, sample_col = "sample")
  330. #' qc$summary
  331. #' filtered_obj <- qc$filtered
  332. #' }
  333. #'
  334. #' @import Seurat
  335. #' @import dplyr
  336. #' @import ggplot2
  337. #' @export
  338. sex_contamination_qc <- function(
  339. obj,
  340. sample_col = "sample",
  341. y_genes = c("UTY","RPS4Y1","ZFY","DDX3Y","KDM5D"),
  342. xist_gene = "XIST",
  343. eps = 1e-6,
  344. female_thresh = 1,
  345. male_thresh = -1,
  346. eryth_genes = c("HBB","HBA1","HBA2","HBE1","HBG1","HBG2","HBM"),
  347. majority_threshold = 0.5,
  348. eryth_percentile = 0.95,
  349. remove_ambiguous = TRUE,
  350. min_sex_score_diff = NULL,
  351. out_file = NULL
  352. ){
  353. cat("Extracting expression matrix (slot = data)...")
  354. Seurat::DefaultAssay(obj) <- "RNA"
  355. expr <- Seurat::GetAssayData(obj, slot = "data")
  356. meta_df <- [email hidden]
  357. # Sex score
  358. cat("Computing sex scores...")
  359. meta_df$XIST <- if (xist_gene %in% rownames(expr)) expr[xist_gene, ] else 0
  360. y_present <- intersect(y_genes, rownames(expr))
  361. meta_df$Ysum <- if (length(y_present) > 0)
  362. Matrix::colSums(expr[y_present, , drop = FALSE])
  363. else 0
  364. meta_df$Ysum[is.na(meta_df$Ysum)] <- 0
  365. meta_df$XIST[is.na(meta_df$XIST)] <- 0
  366. meta_df$sex_score <- log2((meta_df$XIST + eps)/(meta_df$Ysum + eps))
  367. meta_df$sex_call <- ifelse(
  368. meta_df$sex_score > female_thresh, "Female-like",
  369. ifelse(meta_df$sex_score < male_thresh, "Male-like", "Ambiguous")
  370. )
  371. # Erythroid score
  372. cat("Computing erythroid scores...")
  373. eryth_present <- intersect(eryth_genes, rownames(expr))
  374. meta_df$eryth_sum <- if (length(eryth_present) > 0)
  375. Matrix::colSums(expr[eryth_present, , drop = FALSE])
  376. else 0
  377. meta_df$eryth_sum[is.na(meta_df$eryth_sum)] <- 0
  378. eryth_threshold <- as.numeric(
  379. stats::quantile(meta_df$eryth_sum, eryth_percentile, na.rm = TRUE)
  380. )
  381. # Summary per sample
  382. cat("Summarizing per-sample sex composition...")
  383. tbl_summary <- meta_df %>%
  384. dplyr::group_by(.data[[sample_col]]) %>%
  385. dplyr::summarise(
  386. n_cells = dplyr::n(),
  387. frac_female_like = mean(sex_call == "Female-like"),
  388. frac_male_like = mean(sex_call == "Male-like"),
  389. frac_ambiguous = mean(sex_call == "Ambiguous"),
  390. median_sex_score = stats::median(sex_score),
  391. median_eryth_sum = stats::median(eryth_sum),
  392. .groups = "drop"
  393. )
  394. # Infer sample sex
  395. inferred <- tbl_summary %>%
  396. dplyr::mutate(inferred_sex =
  397. ifelse(frac_female_like > majority_threshold, "Female", "Male")) %>%
  398. dplyr::select(.data[[sample_col]], inferred_sex) %>%
  399. tibble::deframe()
  400. meta_df$inferred_sex <- unname(inferred[meta_df[[sample_col]]])
  401. # Discordance
  402. meta_df$sex_discordant <-
  403. (meta_df$inferred_sex == "Female" & meta_df$sex_call == "Male-like") |
  404. (meta_df$inferred_sex == "Male" & meta_df$sex_call == "Female-like")
  405. if (!is.null(min_sex_score_diff)) {
  406. global_median <- stats::median(meta_df$sex_score, na.rm = TRUE)
  407. meta_df$strong_discordance <- meta_df$sex_discordant &
  408. abs(meta_df$sex_score - global_median) >= min_sex_score_diff
  409. } else {
  410. meta_df$strong_discordance <- meta_df$sex_discordant
  411. }
  412. # Flag cells for removal
  413. cat("Flagging cells for removal...")
  414. remove_cells <- meta_df$strong_discordance & (meta_df$eryth_sum > eryth_threshold)
  415. if (remove_ambiguous)
  416. remove_cells <- remove_cells | meta_df$sex_call == "Ambiguous"
  417. cat(sum(remove_cells), " cells flagged.")
  418. # Write metadat back
  419. obj <- Seurat::AddMetaData(obj, metadata = meta_df)
  420. # Subset filtered object
  421. cat("Creating filtered object...")
  422. keep_cells <- rownames(meta_df)[!remove_cells]
  423. filt_obj <- subset(obj, cells = keep_cells)
  424. # Summary for output
  425. removal_summary <- data.frame(
  426. sample = meta_df[[sample_col]],
  427. removed = remove_cells
  428. ) %>%
  429. dplyr::group_by(sample) %>%
  430. dplyr::summarise(
  431. n_removed = sum(removed),
  432. frac_removed = mean(removed),
  433. .groups = "drop"
  434. )
  435. tbl_summary <- tbl_summary %>%
  436. dplyr::left_join(removal_summary, by = "sample") %>%
  437. dplyr::mutate(eryth_threshold = eryth_threshold)
  438. # Output file
  439. if (!is.null(out_file)) {
  440. utils::write.table(tbl_summary, file = out_file, sep = "\t",
  441. quote = FALSE, row.names = FALSE)
  442. }
  443. # Fix seurat matrix names
  444. obj <- fix_seurat_matrix_names(obj)
  445. filt_obj <- fix_seurat_matrix_names(filt_obj)
  446. # Return output
  447. return(list(
  448. object = obj,
  449. filtered = filt_obj,
  450. tbl_summary = tbl_summary,
  451. eryth_threshold = eryth_threshold,
  452. n_removed = sum(remove_cells)
  453. ))
  454. }
  455. genefilter <- function(
  456. obj,
  457. mode = c("B","A","C"),
  458. keep.genes = c("MEG3"),
  459. excl.genes = NULL,
  460. assay = "RNA",
  461. normalize = TRUE,
  462. norm.method = "LogNormalize",
  463. scale.fac = 10000,
  464. find.hvg = TRUE,
  465. n.hvg = 2000,
  466. scale.data = TRUE,
  467. verbose = TRUE
  468. ){
  469. stopifnot(inherits(obj, "Seurat"))
  470. mode <- match.arg(mode)
  471. DefaultAssay(obj) <- assay
  472. genes <- rownames(obj[[assay]])
  473. # robust meta.features accessor (works for Seurat v5)
  474. get_gene_biotype <- function(obj, assay) {
  475. assay_obj <- obj[[assay]]
  476. # Assay5: meta.features only exists if user added it
  477. if (!"meta.features" %in% slotNames(assay_obj)) {
  478. return(NULL)
  479. }
  480. mf <- [email hidden]
  481. if (is.null(mf) || nrow(mf) == 0) return(NULL)
  482. mf <- as.data.frame(mf)
  483. if (!"gene_biotype" %in% colnames(mf)) return(NULL)
  484. x <- mf$gene_biotype
  485. names(x) <- rownames(mf)
  486. return(x)
  487. }
  488. gb <- get_gene_biotype(obj, assay)
  489. # filtering rules
  490. filters <- list(
  491. biotypes_rm = c(
  492. "lincRNA","lncRNA","antisense_RNA","processed_transcript",
  493. "pseudogene","processed_pseudogene","unprocessed_pseudogene",
  494. "transcribed_unprocessed_pseudogene","transcribed_processed_pseudogene",
  495. "unitary_pseudogene","Mt_rRNA","Mt_tRNA","rRNA"
  496. ),
  497. regex_rm = c(
  498. "^MT-",
  499. "^(RPL|RPS)[0-9]+",
  500. "^HB[ABDGEMQZ]",
  501. "^LINC[0-9]+",
  502. "^(AC|AL|AP|RP)[0-9]+",
  503. "^(CTD|CTC)-",
  504. "^LOC[0-9]+",
  505. "^C[0-9]+orf", "-AS[0-9]+$",
  506. "^FAM[0-9]+","^DAZ[0-9]+$",
  507. "^XXBAC", "^RPS4Y[12]$",
  508. "\\.[0-9]+$", "^TTTY[0-9]+$",
  509. "^ENSG[0-9]+$","^USP9Y$",
  510. "^XIST$", "^TSIX$",
  511. "^SRY$", "^ZFY$", "^UTY$"
  512. )
  513. )
  514. genes_up <- toupper(genes)
  515. names(genes_up) <- genes
  516. rm_biotype <- c()
  517. if (!is.null(gb)) {
  518. for (bt in filters$biotypes_rm)
  519. rm_biotype <- c(rm_biotype, names(gb)[gb == bt])
  520. }
  521. rm_regex <- c()
  522. for (rx in filters$regex_rm)
  523. rm_regex <- c(rm_regex, genes[grepl(rx, genes_up)])
  524. must_remove <- unique(c(rm_biotype, rm_regex))
  525. # keep sets
  526. if (!is.null(gb)) {
  527. prot_ok <- names(gb)[gb == "protein_coding"]
  528. } else {
  529. prot_ok <- setdiff(genes, must_remove)
  530. }
  531. if (mode == "A") {
  532. keep <- prot_ok
  533. } else if (mode == "C") {
  534. keep <- setdiff(genes, must_remove)
  535. } else {
  536. keep <- union(prot_ok, keep.genes)
  537. }
  538. if (!is.null(excl.genes))
  539. keep <- setdiff(keep, excl.genes)
  540. keep <- setdiff(keep, must_remove)
  541. removed <- setdiff(genes, keep)
  542. if (verbose) {
  543. message("=== filter_genes (Seurat v5-safe) ===")
  544. message("Genes:", length(genes))
  545. message("Kept :", length(keep))
  546. message("Removed:", length(removed))
  547. }
  548. # subset + normalization
  549. obj_f <- subset(obj, features = keep)
  550. if (normalize)
  551. obj_f <- NormalizeData(obj_f, normalization.method = norm.method, scale.factor = scale.fac)
  552. if (find.hvg)
  553. obj_f <- FindVariableFeatures(obj_f, nfeatures = n.hvg)
  554. if (scale.data)
  555. obj_f <- ScaleData(obj_f, features = VariableFeatures(obj_f))
  556. return(obj_f)
  557. }
  558. #' Filter Outliers in Seurat Clusters Using Local Density
  559. #'
  560. #' @description
  561. #' Removes low-density (outlier) cells within specified clusters of a Seurat object,
  562. #' based on k-nearest neighbor (KNN) distances in a given dimensional reduction (e.g., UMAP or PCA).
  563. #' Optionally visualizes before/after filtering for each cluster and summarizes retained/removed cell counts.
  564. #'
  565. #' @param obj A Seurat object.
  566. #' @param group.by Character string. Metadata column used to define clusters (default: `"seurat_clusters"`).
  567. #' @param reduction Character string. Dimensional reduction to use (e.g., `"umap"`, `"pca"`).
  568. #' @param density.threshold Numeric in (0,1). Quantile of local density defining cutoff (default: `0.95`).
  569. #' @param k Integer. Number of neighbors for KNN-based local density (default: `30`).
  570. #' @param relax Numeric multiplier (>1) to loosen the density threshold (default: `2`).
  571. #' @param clean.all Logical. If `TRUE`, filter all clusters; otherwise specify `target.clusters`.
  572. #' @param target.clusters Vector of cluster identities to clean. Ignored if `clean.all = TRUE`.
  573. #' @param verbose Logical. Print progress and summary (default: `TRUE`).
  574. #' @param color Color for highlighted cells in plots (default: `"red"`).
  575. #' @param pt.size Point size for plotting (default: `1`).
  576. #' @param base.size Base text size for plot titles (default: `10`).
  577. #' @param ncol Integer. Number of columns for the combined plot layout (default: `2`).
  578. #' @param return.plot Logical. Return combined patchwork plot (default: `FALSE`).
  579. #'
  580. #' @return A list with:
  581. #' \itemize{
  582. #' \item \code{obj}: The filtered Seurat object.
  583. #' \item \code{summary}: A data.frame summarizing before/after cell counts per cluster.
  584. #' \item \code{plot}: (optional) Combined before/after UMAP visualization if `return.plot = TRUE`.
  585. #' }
  586. #'
  587. #' @details
  588. #' The function estimates local cell density for each cluster using mean KNN distances.
  589. #' Cells below the density cutoff (low-density regions) are considered outliers and removed.
  590. #' Clusters not listed in `target.clusters` remain untouched.
  591. #'
  592. #' @examples
  593. #' \dontrun{
  594. #' res <- filter_outliers(
  595. #' obj = obj,
  596. #' group.by = "seurat_clusters",
  597. #' target.clusters = c(0,1,2,3),
  598. #' reduction = "umap",
  599. #' density.threshold = 0.95,
  600. #' k = 30,
  601. #' relax = 2,
  602. #' verbose = TRUE,
  603. #' return.plot = TRUE
  604. #' )
  605. #' res$summary
  606. #' print(res$plot)
  607. #' }
  608. #'
  609. #' @import Seurat
  610. #' @import FNN
  611. #' @import patchwork
  612. #' @export
  613. filter_outliers <- function(obj,
  614. group.by = "seurat_clusters",
  615. reduction = "umap",
  616. density.threshold = 0.95,
  617. k = 30,
  618. relax = 2,
  619. clean.all = FALSE,
  620. target.clusters = NULL,
  621. verbose = TRUE,
  622. color = "red",
  623. pt.size = 1,
  624. base.size = 10,
  625. ncol = 2,
  626. return.plot = FALSE) {
  627. require(Seurat)
  628. require(FNN)
  629. require(patchwork)
  630. #Checks
  631. if (!group.by %in% colnames([email hidden]))
  632. stop("Column ", group.by, " not found in metadata.")
  633. if (!reduction %in% Reductions(obj))
  634. stop("Reduction ", reduction, " not found in object.")
  635. obj <- SetIdent(obj, value = group.by)
  636. uniq.clust <- unique(na.omit(as.character([email hidden][[group.by]])))
  637. if (clean.all) {
  638. clusts <- uniq.clust
  639. } else if (!is.null(target.clusters)) {
  640. bad <- setdiff(target.clusters, uniq.clust)
  641. if (length(bad) > 0) stop("Invalid clusters: ", paste(bad, collapse = ", "))
  642. clusts <- target.clusters
  643. } else {
  644. stop("Specify either clean.all = TRUE or target.clusters.")
  645. }
  646. if (verbose) cat("Processing clusters:", paste(clusts, collapse = ", "), "\n")
  647. #Loop
  648. all.keep <- c()
  649. plots <- list()
  650. summ <- data.frame()
  651. emb <- Embeddings(obj, reduction)
  652. for (cl in clusts) {
  653. cells <- rownames([email hidden])[[email hidden][[group.by]] == cl]
  654. if (length(cells) < 2) next
  655. coord <- emb[cells, , drop = FALSE]
  656. kk <- min(k, length(cells) - 1)
  657. dens <- rowMeans(knn.dist(coord, k = kk), na.rm = TRUE)
  658. [email hidden][cells, "dens"] <- dens
  659. thr <- quantile(dens, density.threshold, na.rm = TRUE)
  660. keep <- cells[dens <= thr * relax]
  661. all.keep <- c(all.keep, keep)
  662. summ <- rbind(summ, data.frame(
  663. cluster = cl,
  664. before = length(cells),
  665. after = length(keep),
  666. removed = length(cells) - length(keep),
  667. pct.rm = round((1 - length(keep)/length(cells)) * 100, 2)
  668. ))
  669. p1 <- DimPlot(obj, reduction = reduction, group.by = group.by,
  670. cells.highlight = cells, cols.highlight = color,
  671. sizes.highlight = pt.size) +
  672. NoAxes() + NoLegend() +
  673. ggtitle(paste("Cluster", cl, "(n=", length(cells), ")")) +
  674. theme(plot.title = element_text(hjust = 0.5, size = base.size))
  675. p2 <- DimPlot(obj, reduction = reduction, group.by = group.by,
  676. cells.highlight = keep, cols.highlight = color,
  677. sizes.highlight = pt.size) +
  678. NoAxes() + NoLegend() +
  679. ggtitle(paste("Filtered", cl, "(n=", length(keep),
  680. ")\nCutoff=", round(thr, 2), " K=", kk)) +
  681. theme(plot.title = element_text(hjust = 0.5, size = base.size))
  682. plots[[as.character(cl)]] <- p1 + p2 + patchwork::plot_layout(ncol = 2)
  683. }
  684. #Keep filtered + untouched clusters
  685. all.target <- rownames([email hidden])[[email hidden][[group.by]] %in% clusts]
  686. other.cells <- setdiff(colnames(obj), all.target)
  687. keep.cells <- unique(c(all.keep, other.cells))
  688. filt <- subset(obj, cells = keep.cells)
  689. if (verbose) {
  690. cat("\n=== FILTER SUMMARY ===\n")
  691. print(summ, row.names = FALSE)
  692. cat("Total before:", ncol(obj), "\n")
  693. cat("Total after:", ncol(filt), "\n")
  694. cat("Removed:", ncol(obj) - ncol(filt), "\n")
  695. }
  696. if (return.plot) {
  697. combo <- wrap_plots(plots, ncol = ncol)
  698. return(list(obj = filt, summary = summ, plot = combo))
  699. } else {
  700. return(list(obj = filt, summary = summ))
  701. }
  702. }
  703. #' Compute cluster statistics from Seurat object (multiple group.by)
  704. #'
  705. #' @param object Seurat object
  706. #' @param ident Metadata column for cluster identities (default: "seurat_clusters")
  707. #' @param group.by One or more metadata columns to group by (default: "orig.ident")
  708. #' @param rm.sum Logical; remove total row (default: FALSE)
  709. #' @param rm.pct Logical; hide percentage columns (default: FALSE)
  710. #' @return Data frame of cluster counts and percentages
  711. #' @export
  712. cluster_stats <- function(object, ident = "seurat_clusters",
  713. group.by = "orig.ident", rm.sum = FALSE, rm.pct = FALSE) {
  714. if (!inherits(object, "Seurat")) stop("`object` must be a Seurat object.")
  715. if (!missing(ident)) object <- SetIdent(object, value = ident)
  716. group.by <- as.character(group.by)
  717. missing.cols <- setdiff(group.by, colnames([email hidden]))
  718. if(length(missing.cols)) stop(sprintf("'%s' not in meta.data.", paste(missing.cols, collapse = ", ")))
  719. # Base cluster totals
  720. tbl <- table([email hidden])
  721. stats <- data.frame(cluster = names(tbl), count = as.numeric(tbl), pct = as.numeric(prop.table(tbl)*100))
  722. # Function to compute counts and percent per group.by
  723. compute_group <- function(g) {
  724. tab <- table([email hidden], [email hidden][[g]])
  725. df <- as.data.frame.matrix(tab) %>% tibble::rownames_to_column("cluster")
  726. pct <- prop.table(tab, margin = 2) * 100
  727. pct.df <- as.data.frame.matrix(pct) %>% tibble::rownames_to_column("cluster")
  728. colnames(pct.df)[-1] <- paste0(colnames(pct.df)[-1], ".", g, ".pct")
  729. cbind(df[,-1], pct.df[,-1])
  730. }
  731. if(length(group.by) > 0) {
  732. stats <- cbind(stats, purrr::map_dfc(group.by, compute_group))
  733. }
  734. stats <- janitor::adorn_totals(stats, "row")
  735. if(rm.sum) stats <- stats[1:(nrow(stats)-1), ]
  736. if(rm.pct) stats <- stats[, !grepl("\\.pct$", colnames(stats))]
  737. stats
  738. }
  739. #' Automatically Select the Optimal Number of PCs
  740. #'
  741. #' This function computes an optimal number of principal components (PCs)
  742. #' to use for downstream analysis based on variance explained. It supports
  743. #' any dimensional reduction stored in a Seurat object (e.g., PCA, Harmony).
  744. #'
  745. #' @param object A Seurat object.
  746. #' @param reduction.name Name of the reduction slot to inspect
  747. #' (default: \code{"pca"}). Must exist in \code{object[[reduction.name]]}.
  748. #'
  749. #' @details
  750. #' The selection heuristic uses two criteria:
  751. #'
  752. #' \itemize{
  753. #' \item \strong{co1}: First PC where cumulative variance exceeds 90\% AND
  754. #' the PC-specific variance drops below 5\%.
  755. #'
  756. #' \item \strong{co2}: Last PC where the drop in variance from the previous PC
  757. #' is greater than 0.1\%.
  758. #' }
  759. #'
  760. #' The function returns the larger of the two values, ensuring sufficient
  761. #' dimensionality for clustering and UMAP.
  762. #'
  763. #' @return An integer giving the recommended number of dimensions.
  764. #'
  765. #' @examples
  766. #' \dontrun{
  767. #' pcs <- get_pcs(object, reduction.name = "pca")
  768. #' }
  769. #'
  770. #' @export
  771. #'
  772. get_pcs <- function(object, reduction.name="pca") {
  773. # Check seurat object
  774. if(class(object) != "Seurat") {
  775. message("WARNING: this rds file does not contain a Seurat object! STOP RUNNING THIS SCRIPT")
  776. message("Check the data type by running:")
  777. message("class(obj)")
  778. stop()
  779. }
  780. # Determine percent of variation associated with each PC
  781. pct <- object[[reduction.name]]@stdev / sum(object[[reduction.name]]@stdev)*100
  782. # Calculate cumulative percents for each PC
  783. cumu <- cumsum(pct)
  784. # Determine which PC exhibits cumulative percent greater than 90% and %
  785. # variation associated with the PC as less than 5
  786. co1 <- which(cumu > 90 & pct < 5)[1]
  787. co1
  788. # Determine the difference between variation of PC and subsequent PC and
  789. # selecting last point where change of % of variation is more than 0.1%.
  790. co2 <- sort(which((pct[1:length(pct) - 1] - pct[2:length(pct)]) > 0.1), decreasing = T)[1] + 1
  791. # Minimum of the two calculation
  792. #pcs <- min(co1, co2)
  793. c(co1, co2)
  794. pcs <- max(co1, co2)
  795. #pcs <- max(co1, co2)
  796. return(pcs)
  797. }
  798. #' Compute Unique Gene Expression Statistics
  799. #'
  800. #' This function evaluates a set of genes in a Seurat object and returns:
  801. #' \enumerate{
  802. #' \item The number of selected genes expressed by each cell.
  803. #' \item The number of cells expressing each gene.
  804. #' }
  805. #'
  806. #' @param object A Seurat object containing an RNA assay.
  807. #' @param gene.list Character vector of gene names to evaluate.
  808. #'
  809. #' @return A list containing:
  810. #' \describe{
  811. #' \item{\code{per.cell}}{A data frame with columns \code{Cell} and \code{Unique}.}
  812. #' \item{\code{per.gene}}{A data frame with columns \code{gene} and \code{cells.expressing}.}
  813. #' }
  814. #'
  815. #' @export
  816. get_unique_gene_table <- function(object, gene.list, assay = "RNA", layer = "counts") {
  817. if (!inherits(object, "Seurat"))
  818. stop("`object` must be a Seurat object.")
  819. if (!assay %in% names(object@assays))
  820. stop("Assay not found in object.")
  821. assay_obj <- object[[assay]]
  822. # Access the matrix via @layers (Seurat v5)
  823. if (!layer %in% names(assay_obj@layers))
  824. stop(paste0("Layer '", layer, "' not found in the assay."))
  825. mat <- assay_obj@layers[[layer]]
  826. # Ensure dimnames are set
  827. if (is.null(rownames(mat))) rownames(mat) <- rownames(assay_obj)
  828. if (is.null(colnames(mat))) colnames(mat) <- colnames(object)
  829. genes.present <- intersect(gene.list, rownames(mat))
  830. if (length(genes.present) == 0)
  831. stop("None of the provided genes exist in the object.")
  832. mat <- mat[genes.present, , drop = FALSE]
  833. mat <- mat[Matrix::rowSums(mat > 0) > 0, , drop = FALSE]
  834. per.cell <- data.frame(
  835. Cell = colnames(mat),
  836. Unique = Matrix::colSums(mat > 0)
  837. )
  838. per.gene <- data.frame(
  839. gene = rownames(mat),
  840. cells.expressing = Matrix::rowSums(mat > 0)
  841. )
  842. per.gene <- per.gene[order(per.gene$cells.expressing), ]
  843. list(per.cell = per.cell, per.gene = per.gene)
  844. }
  845. run_decontx <- function(
  846. input,
  847. assay_name = "RNA",
  848. verbose = TRUE,
  849. ...
  850. ) {
  851. library(Seurat)
  852. library(SingleCellExperiment)
  853. library(scater)
  854. library(celda)
  855. library(Matrix)
  856. # Load input
  857. if (inherits(input, "Seurat")) {
  858. if (verbose) cat("Input is a Seurat object\n")
  859. sce <- as.SingleCellExperiment(input)
  860. # Extract original UMAP from Seurat
  861. if ("umap" %in% names(input@reductions)) {
  862. reducedDims(sce)$UMAP_before <- Embeddings(input, "umap")
  863. if (verbose) cat("UMAP_before extracted from Seurat\n")
  864. } else {
  865. reducedDims(sce)$UMAP_before <- NULL
  866. if (verbose) cat("No UMAP found in Seurat\n")
  867. }
  868. } else if (inherits(input, "SingleCellExperiment")) {
  869. sce <- input
  870. if (verbose) cat("Input is SingleCellExperiment\n")
  871. # Preserve existing UMAP
  872. if ("UMAP" %in% names(reducedDims(sce))) {
  873. reducedDims(sce)$UMAP_before <- reducedDims(sce)$UMAP
  874. if (verbose) cat("UMAP_before extracted from SCE\n")
  875. } else {
  876. reducedDims(sce)$UMAP_before <- NULL
  877. if (verbose) cat("No UMAP present in SCE\n")
  878. }
  879. } else if (is.matrix(input) || inherits(input, "dgCMatrix")) {
  880. if (verbose) cat("Input is counts matrix\n")
  881. sce <- SingleCellExperiment(assays=list(counts=input))
  882. reducedDims(sce)$UMAP_before <- NULL
  883. } else {
  884. stop("Input must be Seurat, SCE, or matrix.")
  885. }
  886. # Run decontx
  887. if (verbose) cat("Running DecontX...\n")
  888. sce <- decontX(sce, ...)
  889. cleaned <- decontXcounts(sce)
  890. cleaned <- round(cleaned)
  891. # Compute umap after
  892. if (verbose) cat("Computing UMAP_after from DecontX-cleaned counts...\n")
  893. sce <- runUMAP(sce, exprs_values = "decontXcounts")
  894. reducedDims(sce)$UMAP_after <- reducedDims(sce)$UMAP
  895. reducedDims(sce)$UMAP <- NULL # avoid confusion
  896. # Return both
  897. return(list(
  898. sce = sce,
  899. UMAP_before = reducedDims(sce)$UMAP_before,
  900. UMAP_after = reducedDims(sce)$UMAP_after,
  901. cleaned_counts = cleaned
  902. ))
  903. }
  904. save_heatmap <- function(
  905. ht,
  906. filename,
  907. width = 8,
  908. height = 6,
  909. formats = c("pdf"),
  910. dpi = 600
  911. ) {
  912. stopifnot(!missing(ht))
  913. stopifnot(is.character(filename))
  914. stopifnot(is.numeric(width), is.numeric(height))
  915. for (fmt in formats) {
  916. fmt <- tolower(fmt)
  917. file_out <- paste0(filename, ".", fmt)
  918. if (fmt == "pdf") {
  919. pdf(
  920. file_out,
  921. width = width,
  922. height = height,
  923. useDingbats = FALSE
  924. )
  925. } else if (fmt == "png") {
  926. png(
  927. file_out,
  928. width = width,
  929. height = height,
  930. units = "in",
  931. res = dpi
  932. )
  933. } else if (fmt == "tiff") {
  934. tiff(
  935. file_out,
  936. width = width,
  937. height = height,
  938. units = "in",
  939. res = dpi,
  940. compression = "lzw"
  941. )
  942. } else if (fmt %in% c("jpg", "jpeg")) {
  943. jpeg(
  944. file_out,
  945. width = width,
  946. height = height,
  947. units = "in",
  948. res = dpi,
  949. quality = 100
  950. )
  951. } else {
  952. stop("Unsupported format: ", fmt)
  953. }
  954. # Draw AFTER opening device
  955. ComplexHeatmap::draw(ht)
  956. dev.off()
  957. }
  958. invisible(TRUE)
  959. }
  960. #' Fix matrix names in Seurat v5 objects (robust)
  961. #'
  962. #' Ensures that all matrices in a Seurat RNA assay (`layers`, `data`, `scale.data`)
  963. #' have proper row and column names. Skips slots that do not exist or are empty.
  964. #'
  965. #' @param sobj A Seurat object
  966. #' @return A Seurat object with all matrix names fixed
  967. fix_seurat_matrix_names <- function(sobj) {
  968. assay <- sobj[["RNA"]]
  969. for (slot.name in c("layers", "data", "scale.data")) {
  970. # Handle layers specially
  971. if (slot.name == "layers" && !is.null(assay@layers$counts)) {
  972. mat <- assay@layers$counts
  973. if (!is.null(mat) && length(dim(mat)) == 2) {
  974. if (is.null(rownames(mat))) rownames(mat) <- rownames(assay)
  975. if (is.null(colnames(mat))) colnames(mat) <- colnames(sobj)
  976. assay@layers$counts <- mat
  977. }
  978. }
  979. # Other slots
  980. else if (slot.name %in% slotNames(assay) && !is.null(slot(assay, slot.name))) {
  981. mat <- slot(assay, slot.name)
  982. if (!is.null(mat) && length(dim(mat)) == 2) {
  983. if (is.null(rownames(mat))) rownames(mat) <- rownames(assay)
  984. if (is.null(colnames(mat))) colnames(mat) <- colnames(sobj)
  985. slot(assay, slot.name) <- mat
  986. }
  987. }
  988. }
  989. sobj[["RNA"]] <- assay
  990. return(sobj)
  991. }
  992. savefig <- function(
  993. filename,
  994. fig = NULL,
  995. layout = c("single", "double"),
  996. type = c("ggplot", "heatmap", "baseplot"),
  997. format = c("pdf", "png", "tiff"),
  998. width = NULL,
  999. height = NULL,
  1000. dpi = 600
  1001. ){
  1002. layout <- match.arg(layout)
  1003. type <- match.arg(type)
  1004. formats <- format # allow vector of formats
  1005. # Nature Communications sizing defaults
  1006. default_width <- ifelse(layout == "single", 3.5, 7.2)
  1007. default_height <- ifelse(type %in% c("heatmap", "baseplot"), 8, 5)
  1008. width <- width %||% default_width
  1009. height <- height %||% default_height
  1010. for(fmt in formats) {
  1011. out_file <- filename
  1012. if(!grepl(paste0("\\.", fmt, "$"), out_file))
  1013. out_file <- paste0(out_file, ".", fmt)
  1014. cat("Saving ", out_file,
  1015. "\n layout=", layout,
  1016. "\n type=", type,
  1017. "\n size=", width, "×", height, " inches @ DPI=", dpi,
  1018. "\n format=", fmt, "\n\n")
  1019. # PDF
  1020. if(fmt == "pdf") {
  1021. pdf(out_file, width = width, height = height, useDingbats = FALSE)
  1022. if(type == "ggplot") print(fig)
  1023. if(type %in% c("heatmap", "baseplot")) eval(fig)
  1024. dev.off()
  1025. next
  1026. }
  1027. # PNG / TIFF
  1028. if(fmt %in% c("png", "tiff")) {
  1029. if(type == "ggplot") {
  1030. ggsave(out_file, plot = fig,
  1031. width = width, height = height,
  1032. units = "in", dpi = dpi)
  1033. } else if(type %in% c("heatmap", "baseplot")) {
  1034. if(fmt == "png")
  1035. png(out_file, width = width, height = height, units = "in", res = dpi)
  1036. if(fmt == "tiff")
  1037. tiff(out_file, width = width, height = height, units = "in",
  1038. res = dpi, compression = "lzw")
  1039. eval(fig)
  1040. dev.off()
  1041. }
  1042. }
  1043. }
  1044. invisible(filename)
  1045. }
  1046. #' Colorize Values Using Various Color Palettes
  1047. #'
  1048. #' This function assigns colors to unique values in a vector using a variety of distinct and continuous color palettes.
  1049. #' It supports both discrete and continuous color schemes, including RColorBrewer, Viridis, and custom palettes.
  1050. #'
  1051. #' @param x A vector of values to be colorized.
  1052. #' @param theme A string specifying the color theme. Available themes:
  1053. #' * **Distinct palettes**: `"alphabet"`, `"tube"`, `"tol"`, `"glasbey"`, `"tableau"`, `"bright"`, `"pastel"`, `"cList"``
  1054. #' * **RColorBrewer palettes**:
  1055. #' - *Sequential*: `"Blues"`, `"BuGn"`, `"BuPu"`, `"GnBu"`, `"Greens"`, `"Greys"`, `"Oranges"`, `"OrRd"`, `"PuBu"`,
  1056. #' `"PuBuGn"`, `"PuRd"`, `"Purples"`, `"RdPu"`, `"Reds"`, `"YlGn"`, `"YlGnBu"`, `"YlOrBr"`, `"YlOrRd"`
  1057. #' - *Qualitative*: `"Accent"`, `"Dark2"`, `"Paired"`, `"Pastel1"`, `"Pastel2"`, `"Set1"`, `"Set2"`, `"Set3"`
  1058. #' - *Divergent*: `"BrBG"`, `"PiYG"`, `"PRGn"`, `"PuOr"`, `"RdBu"`, `"RdGy"`, `"RdYlBu"`, `"RdYlGn"`, `"Spectral"`
  1059. #' * **Viridis palettes**: `"viridis"`, `"magma"`, `"plasma"`, `"cividis"`, `"inferno"`
  1060. #' * **Random colors**: `"random"`
  1061. #' @param bgval A value in `x` that should be assigned a background color (`"#CCCCCC"` by default). Default is `NULL` (no special background value).
  1062. #' @param n_colors Number of colors to generate for continuous palettes. Default is `100`.
  1063. #'
  1064. #' @return A named vector of hex color codes, where names correspond to unique values in `x`.
  1065. #'
  1066. #' @examples
  1067. #' # Apply Viridis color palette
  1068. #' colorize(1:10, theme = "viridis")
  1069. #'
  1070. #' # Apply RColorBrewer "Blues" palette
  1071. #' colorize(letters[1:10], theme = "Blues")
  1072. #'
  1073. #' # Use a distinct color palette (Glasbey)
  1074. #' colorize(1:15, theme = "glasbey")
  1075. #'
  1076. #' @import viridis
  1077. #' @import RColorBrewer
  1078. #' @export
  1079. colorize <- function(x, theme = "alphabet", bgval = NULL, n_colors = 100, reverse = FALSE) {
  1080. require(viridis)
  1081. require(RColorBrewer)
  1082. # Define distinct color palettes
  1083. color_alphabet <- matrix(c(
  1084. 240, 163, 255, 0, 117, 220, 153, 63, 0, 76, 0, 92, 0, 92, 49, 43, 206, 72, 255, 204, 153,
  1085. 128, 128, 128, 148, 255, 181, 143, 124, 0, 157, 204, 0, 194, 0, 136, 0, 51, 128, 255, 164, 5,
  1086. 255, 168, 187, 66, 102, 0, 255, 0, 16, 94, 241, 242, 0, 153, 143, 224, 255, 102, 116, 10, 255,
  1087. 153, 0, 0, 255, 255, 128, 255, 255, 0, 255, 80, 5
  1088. ), ncol = 3, byrow = TRUE) / 256
  1089. tube_colors <- c(
  1090. "#B36305", "#E32017", "#FFD300", "#00782A", "#F3A9BB",
  1091. "#A0A5A9", "#9B0056", "#000000", "#003688", "#0098D4",
  1092. "#95CDBA", "#00A4A7", "#EE7C0E", "#84B817", "#E21836",
  1093. "#7156A5"
  1094. )
  1095. tol_colors <- c(
  1096. "#332288", "#88CCEE", "#44AA99", "#117733", "#999933",
  1097. "#DDCC77", "#CC6677", "#882255", "#AA4499", "#DDDDDD",
  1098. "#E69F00", "#56B4E9"
  1099. )
  1100. glasbey_colors <- c(
  1101. "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF",
  1102. "#00FFFF", "#800000", "#808000", "#008000", "#800080",
  1103. "#808080", "#C0C0C0", "#008080", "#000080", "#FFA500"
  1104. )
  1105. tableau_colors <- c(
  1106. "#1F77B4", "#FF7F0E", "#2CA02C", "#D62728", "#9467BD",
  1107. "#8C564B", "#E377C2", "#BCBD22", "#17BECF",
  1108. "#AEC7E8", "#FFBB78", "#98DF8A", "#FF9896", "#C5B0D5",
  1109. "#C49C94", "#F7B6D2", "#DBDB8D", "#9EDAE5"
  1110. )
  1111. bright_colors <- c(
  1112. "#E6194B", "#3CB44B", "#FFE119", "#4363D8", "#F58231",
  1113. "#911EB4", "#42D4F4", "#F032E6", "#BFEF45", "#FABEBE"
  1114. )
  1115. pastel_colors <- c(
  1116. "#FFB3BA", "#FFDFBA", "#FFFFBA", "#BAFFC9", "#BAE1FF",
  1117. "#E6C0E9", "#D9C3A1", "#C4E5F5", "#F6D7A7", "#D1E8E2"
  1118. )
  1119. cList = list(c("grey85","#FFF7EC","#FEE8C8","#FDD49E","#FDBB84",
  1120. "#FC8D59","#EF6548","#D7301F","#B30000","#7F0000"),
  1121. c("#4575B4","#74ADD1","#ABD9E9","#E0F3F8","#FFFFBF",
  1122. "#FEE090","#FDAE61","#F46D43","#D73027")[c(1,1:9,9)],
  1123. c("#FDE725","#AADC32","#5DC863","#27AD81","#21908C",
  1124. "#2C728E","#3B528B","#472D7B","#440154"))
  1125. # cList = c("grey85","#FFF7EC","#FEE8C8","#FDD49E","#FDBB84", "#FC8D59","#EF6548","#D7301F","#B30000","#7F0000", "#4575B4","#74ADD1","#ABD9E9","#E0F3F8","#FFFFBF",
  1126. # "#FEE090","#FDAE61","#F46D43","#D73027", "#FDE725","#AADC32","#5DC863","#27AD81","#21908C", "#2C728E","#3B528B","#472D7B","#440154")
  1127. viridis_colors <- viridis(n_colors, option = "viridis")
  1128. magma_colors <- viridis(n_colors, option = "magma")
  1129. plasma_colors <- viridis(n_colors, option = "plasma")
  1130. inferno_colors <- viridis(n_colors, option = "inferno")
  1131. cividis_colors <- viridis(n_colors, option = "cividis")
  1132. brewer_palettes <- c('Blues', 'BuGn', 'BuPu', 'GnBu', 'Greens', 'Greys', 'Oranges', 'OrRd', 'PuBu',
  1133. 'PuBuGn', 'PuRd', 'Purples', 'RdPu', 'Reds', 'YlGn', 'YlGnBu', 'YlOrBr', 'YlOrRd',
  1134. "Accent", "Dark2", "Paired", "Pastel1", "Pastel2", "Set1", "Set2", "Set3",
  1135. "BrBG", "PiYG", "PRGn", "PuOr", "RdBu", "RdGy", "RdYlBu", "RdYlGn", "Spectral")
  1136. set.seed(42)
  1137. random_colors <- rgb(runif(n_colors), runif(n_colors), runif(n_colors))
  1138. # Choose color theme
  1139. if (theme == "alphabet") {
  1140. colors <- apply(rbind(color_alphabet, 1 - (1 - color_alphabet) / 2, color_alphabet / 2), 1,
  1141. function(row) rgb(row[1], row[2], row[3]))
  1142. } else if (theme == "tube") {
  1143. colors <- tube_colors
  1144. } else if (theme == "cList") {
  1145. colors <- cList
  1146. } else if (theme == "tol") {
  1147. colors <- tol_colors
  1148. } else if (theme == "glasbey") {
  1149. colors <- glasbey_colors
  1150. } else if (theme == "tableau") {
  1151. colors <- tableau_colors
  1152. } else if (theme == "bright") {
  1153. colors <- bright_colors
  1154. } else if (theme == "pastel") {
  1155. colors <- pastel_colors
  1156. } else if (theme == "random") {
  1157. colors <- random_colors
  1158. } else if (theme %in% brewer_palettes) {
  1159. colors <- colorRampPalette(brewer.pal(8, theme))(n_colors)
  1160. } else if (theme == "viridis") {
  1161. colors <- viridis_colors
  1162. } else if (theme == "magma") {
  1163. colors <- magma_colors
  1164. } else if (theme == "plasma") {
  1165. colors <- plasma_colors
  1166. } else if (theme == "cividis") {
  1167. colors <- cividis_colors
  1168. } else if (theme == "inferno") {
  1169. colors <- inferno_colors
  1170. } else {
  1171. stop("Invalid theme.")
  1172. }
  1173. # Reverse colors if needed
  1174. if (reverse) {
  1175. colors <- rev(colors)
  1176. }
  1177. unique_vals <- unique(x)
  1178. encoded_vals <- match(x, unique_vals) - 1
  1179. colors <- colors[(encoded_vals %% length(colors)) + 1]
  1180. if (!is.null(bgval)) {
  1181. colors[x == bgval] <- "#CCCCCC"
  1182. }
  1183. names(colors) <- unique_vals
  1184. return(colors)
  1185. }
  1186. #' Safe subset of Seurat object
  1187. #'
  1188. #' Wrapper around Seurat::subset that fixes matrix names automatically.
  1189. #'
  1190. #' @param sobj Seurat object
  1191. #' @param ... Arguments passed to Seurat::subset
  1192. #' @return Subsetted Seurat object
  1193. get_subset <- function(sobj, group.by = NULL, invert = FALSE,...) {
  1194. if (!is.null(group.by)) sobj <- SetIdent(sobj, value = group.by)
  1195. sobj2 <- subset(sobj, invert = invert,...)
  1196. sobj2 <- fix_seurat_matrix_names(sobj2)
  1197. return(sobj2)
  1198. }
  1199. #' Safe merge of Seurat objects
  1200. #'
  1201. #' Wrapper around Seurat::merge that fixes matrix names automatically.
  1202. #'
  1203. #' @param x Seurat object
  1204. #' @param y Seurat object
  1205. #' @param ... Additional arguments to Seurat::merge
  1206. #' @return Merged Seurat object
  1207. get_merge <- function(obj.list) {
  1208. if (length(obj.list) == 0) stop("No Seurat objects provided")
  1209. merged <- Reduce(merge, obj.list)
  1210. merged <- JoinLayers(merged)
  1211. assay <- merged[["RNA"]]
  1212. assay@layers$counts <- GetAssayData(merged, slot = "counts")
  1213. merged[["RNA"]] <- assay
  1214. merged <- fix_seurat_matrix_names(merged)
  1215. return(merged)
  1216. }
  1217. #' Load multiple 10X/Seurat samples with optional merge and save
  1218. #'
  1219. #' @param sample.dirs Character vector of sample folder names
  1220. #' @param parent.dir Parent directory containing sample folders
  1221. #' @param use.filtered Logical, whether to use filtered matrices (default TRUE)
  1222. #' @param save.dir Directory to save individual Seurat objects (default NULL)
  1223. #' @param merge Logical, whether to merge all Seurat objects (default FALSE)
  1224. #' @return List of Seurat objects or a single merged Seurat object
  1225. #' @examples
  1226. #' objs <- load_seurat(c("FN_S1256","FN_S3478"), parent.dir = "data/processed")
  1227. load_seurat <- function(sample.dirs,
  1228. parent.dir,
  1229. use.filtered = TRUE,
  1230. save.dir = NULL,
  1231. merge = FALSE,
  1232. verbose = TRUE) {
  1233. suppressPackageStartupMessages({
  1234. library(Seurat)
  1235. library(Matrix)
  1236. })
  1237. load_one <- function(sample.name) {
  1238. log <- function(...) if (verbose) cat(..., "\n")
  1239. log("\n[INFO] Loading sample:", sample.name)
  1240. samp.path <- file.path(parent.dir, sample.name)
  1241. if (!dir.exists(samp.path)) {
  1242. stop("[ERROR] Sample folder not found: ", samp.path)
  1243. }
  1244. # Patterns
  1245. if (use.filtered) {
  1246. h5.pattern <- "filtered_feature_bc_matrix\\.h5$"
  1247. dir.pattern <- "filtered_feature_bc_matrix$"
  1248. } else {
  1249. h5.pattern <- "raw_feature_bc_matrix\\.h5$"
  1250. dir.pattern <- "raw_feature_bc_matrix$"
  1251. }
  1252. # Find H5 files
  1253. h5.files <- list.files(
  1254. samp.path,
  1255. pattern = h5.pattern,
  1256. recursive = TRUE,
  1257. full.names = TRUE
  1258. )
  1259. # Find matrix directories
  1260. dirs <- list.dirs(
  1261. samp.path,
  1262. recursive = TRUE,
  1263. full.names = TRUE
  1264. )
  1265. dirs <- dirs[grepl(dir.pattern, dirs)]
  1266. log(" Found", length(h5.files), "H5 file(s)")
  1267. log(" Found", length(dirs), "matrix folder(s)")
  1268. if (length(h5.files) == 0 && length(dirs) == 0) {
  1269. stop("[ERROR] No valid 10X input found for sample: ", sample.name)
  1270. }
  1271. # Read counts
  1272. counts <- if (length(h5.files) > 0) {
  1273. log(" → Reading H5:", basename(h5.files[1]))
  1274. Read10X_h5(h5.files[1])
  1275. } else {
  1276. log(" → Reading MTX folder:", dirs[1])
  1277. Read10X(dirs[1])
  1278. }
  1279. # Create Seurat object
  1280. log(" Creating Seurat object...")
  1281. sobj <- CreateSeuratObject(
  1282. counts = counts,
  1283. project = sample.name
  1284. )
  1285. # Optional matrix-name fix
  1286. if (exists("fix_seurat_matrix_names")) {
  1287. log(" Fixing matrix names...")
  1288. sobj <- fix_seurat_matrix_names(sobj)
  1289. }
  1290. log(" Done:",
  1291. ncol(sobj), "cells x",
  1292. nrow(sobj), "features")
  1293. return(sobj)
  1294. }
  1295. cat("[INFO] Loading samples...\n")
  1296. objs <- setNames(lapply(sample.dirs, load_one), sample.dirs)
  1297. cat("[INFO] All samples loaded\n")
  1298. # Save individual objects
  1299. if (!is.null(save.dir)) {
  1300. dir.create(save.dir, recursive = TRUE, showWarnings = FALSE)
  1301. cat("[INFO] Saving Seurat objects to:", save.dir, "\n")
  1302. for (n in names(objs)) {
  1303. saveRDS(objs[[n]], file.path(save.dir, paste0(n, ".rds")))
  1304. }
  1305. }
  1306. # Merge if requested
  1307. if (merge && length(objs) > 1) {
  1308. cat("[INFO] Merging objects...\n")
  1309. merged <- Reduce(function(x, y) merge(x, y), objs)
  1310. if (exists("fix_seurat_matrix_names")) {
  1311. merged <- fix_seurat_matrix_names(merged)
  1312. }
  1313. cat("[INFO] Merged object:",
  1314. ncol(merged), "cells x",
  1315. nrow(merged), "features\n")
  1316. return(merged)
  1317. }
  1318. return(objs)
  1319. }
  1320. #' Split a Seurat object by metadata and fix matrix names
  1321. #'
  1322. #' @param object A Seurat object
  1323. #' @param split.by Metadata column name to split by
  1324. #' @return A named list of Seurat objects with @layers$counts fixed
  1325. #' @examples
  1326. #' seurat.list <- split_objectects(merged, split.by = "BulkSample")
  1327. split_objectects <- function(object, split.by) {
  1328. require(Seurat)
  1329. objs <- Seurat::SplitObject(object, split.by = split.by)
  1330. objs <- lapply(objs, function(sobj) {
  1331. # Ensure @layers$counts exist
  1332. assay <- sobj[["RNA"]]
  1333. assay@layers$counts <- Seurat::GetAssayData(sobj, slot = "counts")
  1334. sobj[["RNA"]] <- assay
  1335. # Fix row/colnames
  1336. sobj <- fix_seurat_matrix_names(sobj)
  1337. return(sobj)
  1338. })
  1339. return(objs)
  1340. }
  1341. subset_seurat <- function(object,
  1342. group.by = NULL,
  1343. use.harmony = TRUE,
  1344. harmony.group = "orig.ident",
  1345. ndims = 50,
  1346. resolution = 0.5,
  1347. min.dist = 0.3,
  1348. spread = 1,
  1349. preprocess = TRUE,
  1350. ...) {
  1351. stopifnot(inherits(object, "Seurat"))
  1352. # Set identities
  1353. if (!is.null(group.by)) {
  1354. object <- Seurat::SetIdent(object, value = object[[group.by]][,1])
  1355. }
  1356. # Subset
  1357. result <- tryCatch(subset(object, ...), error = function(e) {
  1358. stop("Subsetting failed: ", e$message)
  1359. })
  1360. if (preprocess) {
  1361. # Normalize + variable features
  1362. result <- Seurat::NormalizeData(result)
  1363. result <- Seurat::FindVariableFeatures(result)
  1364. if (length(unique(Seurat::Idents(result))) > 1) {
  1365. result <- Seurat::ScaleData(result)
  1366. }
  1367. # PCA / Harmony
  1368. if (use.harmony && length(unique([email hidden][[harmony.group]])) > 1) {
  1369. result <- harmony::RunHarmony(result, group.by.vars = harmony.group)
  1370. reduction <- "harmony"
  1371. n_reduc_dims <- ncol(result@reductions[[reduction]]@cell.embeddings)
  1372. } else {
  1373. result <- Seurat::RunPCA(result, npcs = min(ndims, 50))
  1374. reduction <- "pca"
  1375. n_reduc_dims <- ncol(result@reductions[[reduction]]@cell.embeddings)
  1376. }
  1377. dims_use <- seq_len(min(ndims, n_reduc_dims))
  1378. # Neighbors + Clusters + UMAP
  1379. graph_name <- "RNA_nn"
  1380. result <- Seurat::FindNeighbors(result, dims = dims_use, reduction = reduction, graph.name = graph_name)
  1381. result <- Seurat::FindClusters(result, resolution = resolution, graph.name = graph_name)
  1382. result <- Seurat::RunUMAP(result, dims = dims_use, reduction = reduction, min.dist = min.dist, spread = spread)
  1383. }
  1384. return(result)
  1385. }
  1386. #' Select cells from a Seurat object based on gene expression, clusters, and metadata
  1387. #'
  1388. #' @description
  1389. #' `cellpick()` selects cells from a Seurat object using positive and/or negative
  1390. #' gene expression criteria, optional cluster restrictions, and flexible metadata
  1391. #' filtering (categorical or numeric ranges).
  1392. #'
  1393. #' @param obj A Seurat object.
  1394. #' @param pos.genes Character vector of genes that should be expressed.
  1395. #' @param neg.genes Character vector of genes that must NOT be expressed (default NULL).
  1396. #' @param slot Assay slot to use for expression values (default "data").
  1397. #' @param expr.thresh Numeric expr.thresh above which a gene is considered expressed (default 0).
  1398. #' @param min.genes Minimum number of `pos.genes` that must be expressed
  1399. #' (default 1; use length(pos.genes) to require all).
  1400. #' @param clusters Optional vector of cluster IDs to retain (default NULL).
  1401. #' @param cluster.col Metadata column containing cluster identities
  1402. #' (default NULL; uses `Idents(obj)`).
  1403. #' @param meta.col Metadata column to filter on (default NULL).
  1404. #' @param meta.vals Values or numeric range (length 2) used to filter `meta.col`.
  1405. #' @param return.obj Logical; if TRUE, return a Seurat object subset
  1406. #' instead of cell names (default FALSE).
  1407. #' @param verbose Logical; print filtering diagnostics (default FALSE).
  1408. #'
  1409. #' @return
  1410. #' Character vector of selected cell names, or a Seurat object if `return.obj = TRUE`.
  1411. #'
  1412. #' @export
  1413. #'
  1414. #' @examples
  1415. #' # Olfactory HBC selection
  1416. #' cells <- cellpick(
  1417. #' obj,
  1418. #' pos.genes = c("TP63", "KRT5", "KRT14"),
  1419. #' neg.genes = c("KRT13"),
  1420. #' min.genes = 2,
  1421. #' cluster.col = "seurat_clusters",
  1422. #' clusters = 16,
  1423. #' meta.col = "group",
  1424. #' meta.vals = c("PCW10", "PCW12"),
  1425. #' verbose = TRUE
  1426. #' )
  1427. cellpick <- function(
  1428. obj,
  1429. pos.genes,
  1430. neg.genes = NULL,
  1431. slot = "data",
  1432. expr.thresh = 0,
  1433. min.genes = 1,
  1434. clusters = NULL,
  1435. cluster.col = NULL,
  1436. meta.col = NULL,
  1437. meta.vals = NULL,
  1438. return.obj = FALSE,
  1439. verbose = FALSE
  1440. ) {
  1441. stopifnot(inherits(obj, "Seurat"))
  1442. stopifnot(length(pos.genes) >= 1)
  1443. # Expression matrix
  1444. mat <- Seurat::GetAssayData(obj, slot = slot)
  1445. missing <- setdiff(pos.genes, rownames(mat))
  1446. if (length(missing) > 0) {
  1447. stop("Positive gene(s) not found: ", paste(missing, collapse = ", "))
  1448. }
  1449. # 2. Positive gene filtering
  1450. expr.pos <- mat[pos.genes, , drop = FALSE] > expr.thresh
  1451. keep.pos <- Matrix::colSums(expr.pos) >= min.genes
  1452. cells <- colnames(mat)[keep.pos]
  1453. if (verbose) message("After positive genes: ", length(cells))
  1454. # Negative gene filtering
  1455. if (!is.null(neg.genes)) {
  1456. missing.neg <- setdiff(neg.genes, rownames(mat))
  1457. if (length(missing.neg) > 0) {
  1458. stop("Negative gene(s) not found: ", paste(missing.neg, collapse = ", "))
  1459. }
  1460. expr.neg <- mat[neg.genes, , drop = FALSE] > expr.thresh
  1461. keep.neg <- Matrix::colSums(expr.neg) == 0
  1462. cells <- intersect(cells, colnames(mat)[keep.neg])
  1463. if (verbose) message("After negative genes: ", length(cells))
  1464. }
  1465. # Cluster restriction
  1466. if (!is.null(clusters)) {
  1467. clust <- if (is.null(cluster.col)) {
  1468. as.character(Seurat::Idents(obj))
  1469. } else {
  1470. if (!cluster.col %in% colnames([email hidden])) {
  1471. stop("cluster.col not found in [email hidden]")
  1472. }
  1473. as.character([email hidden][[cluster.col]])
  1474. }
  1475. names(clust) <- colnames(obj)
  1476. cells <- intersect(cells, names(clust)[clust %in% clusters])
  1477. if (verbose) message("After cluster filter: ", length(cells))
  1478. }
  1479. # Metadata filtering (generalized)
  1480. if (!is.null(meta.col)) {
  1481. if (!meta.col %in% colnames([email hidden])) {
  1482. stop("meta.col not found in [email hidden]")
  1483. }
  1484. if (is.null(meta.vals)) {
  1485. stop("meta.vals must be provided when meta.col is used")
  1486. }
  1487. meta <- [email hidden][[meta.col]]
  1488. names(meta) <- colnames(obj)
  1489. # Attempt numeric coercion for character metadata
  1490. meta.num <- suppressWarnings(as.numeric(gsub("[^0-9.-]", "", meta)))
  1491. if (all(!is.na(meta.num)) && length(meta.vals) == 2) {
  1492. keep.meta <- meta.num >= meta.vals[1] & meta.num <= meta.vals[2]
  1493. } else {
  1494. keep.meta <- meta %in% meta.vals
  1495. }
  1496. cells <- intersect(cells, names(meta)[keep.meta])
  1497. if (verbose) message("After metadata filter: ", length(cells))
  1498. }
  1499. # Return
  1500. if (return.obj) {
  1501. return(subset(obj, cells = cells))
  1502. }
  1503. return(cells)
  1504. }
  1505. #'#' Compute OR Gene Dominance Scores in Single-Cell RNA-seq Data
  1506. #'
  1507. #' This function computes a dominance score for olfactory receptor (OR) genes in each cell
  1508. #' of a Seurat object. The dominance score quantifies how strongly the top-expressed OR
  1509. #' dominates over the second-most expressed OR, adjusted for sparse single-cell expression.
  1510. #'
  1511. #' @param object A Seurat object containing single-cell RNA-seq data.
  1512. #' @param or_genes A character vector of OR gene names to consider.
  1513. #' @param assay Character; the assay name in Seurat object (default: "RNA").
  1514. #' @param slot Character; the assay slot to use (default: "data").
  1515. #' @param epsilon Numeric; a small number to prevent division by zero (default: 1e-9).
  1516. #' @param scale_factor Numeric; multiplier for top OR expression to scale dominance (default: 10).
  1517. #' @param exponent Numeric; exponent to raise the scaled log expression (default: 1).
  1518. #'
  1519. #' @return A data frame with one row per cell containing:
  1520. #' \describe{
  1521. #' \item{top_expr}{Expression of the top-expressed OR gene.}
  1522. #' \item{second_expr}{Expression of the second-highest OR gene.}
  1523. #' \item{dominance_score}{Computed dominance score.}
  1524. #' \item{cell}{Cell barcode or identifier.}
  1525. #' \item{n_OR_expressed}{Number of OR genes with nonzero expression in the cell.}
  1526. #' \item{PCW}{PCW metadata from the Seurat object.}
  1527. #' \item{cluster}{Cell identity from Seurat object.}
  1528. #' \item{cell_group}{Coarse cell grouping (mOSN, iOSN, Others).}
  1529. #' \item{dominance_bin}{Binned dominance score (0–1, 1–2, …, >6).}
  1530. #' }
  1531. #'
  1532. #' @examples
  1533. #' \dontrun{
  1534. #' Idents(sub) <- "ann_level_2"
  1535. #' or_genes <- intersect(ORs$Gene_name, rownames(sub))
  1536. #' df <- compute_OR_dominance(sub, or_genes)
  1537. #' head(df)
  1538. #' table(df$dominance_bin)
  1539. #' }
  1540. #'
  1541. #' @export
  1542. #'
  1543. compute_OR_dominance <- function(
  1544. object,
  1545. or_genes,
  1546. assay = "RNA",
  1547. slot = "data",
  1548. epsilon = 1e-9,
  1549. scale_factor = 10,
  1550. exponent = 1
  1551. ){
  1552. # --- Load required packages ---
  1553. require(dplyr)
  1554. require(Seurat)
  1555. require(rlang)
  1556. # --- Filter OR genes present in Seurat object ---
  1557. or_genes <- intersect(or_genes, rownames(object))
  1558. if(length(or_genes) == 0) stop("No OR genes found in the Seurat object.")
  1559. # --- Extract expression matrix ---
  1560. or_mat <- as.matrix(GetAssayData(object, assay = assay, slot = slot)[or_genes, , drop = FALSE])
  1561. # --- Function to compute scaled dominance per cell ---
  1562. compute_scaled_dominance <- function(x) {
  1563. x_sorted <- sort(x, decreasing = TRUE)
  1564. x1 <- x_sorted[1]
  1565. x2 <- ifelse(length(x_sorted) >= 2, x_sorted[2], 0)
  1566. if (x1 == 0) return(c(top_expr = 0, second_expr = 0, dominance_score = 0))
  1567. # --- Scaled for sparse single-cell data ---
  1568. score <- ((x1 - x2) / (x1 + x2 + epsilon)) * (log1p(scale_factor * x1) ^ exponent)
  1569. return(c(top_expr = x1, second_expr = x2, dominance_score = score))
  1570. }
  1571. # --- Apply dominance calculation to all cells ---
  1572. dom_vals <- t(apply(or_mat, 2, compute_scaled_dominance))
  1573. dom_vals <- as.data.frame(dom_vals)
  1574. # --- Ensure correct column names ---
  1575. colnames(dom_vals) <- c("top_expr", "second_expr", "dominance_score")
  1576. # --- Construct output dataframe ---
  1577. df <- dom_vals %>%
  1578. mutate(
  1579. cell = colnames(or_mat),
  1580. n_OR_expressed = colSums(or_mat > 0),
  1581. PCW = object$PCW,
  1582. cluster = Idents(object)
  1583. ) %>%
  1584. mutate(
  1585. cell_group = case_when(
  1586. cluster %in% c("GBC") ~ "GBC",
  1587. cluster %in% c("INP") ~ "INP",
  1588. cluster %in% c("iOSN") ~ "iOSN",
  1589. TRUE ~ "Others"
  1590. ),
  1591. dominance_bin = cut(
  1592. dominance_score,
  1593. breaks = c(0, 1, 2, 3, 4, 5, 6, Inf),
  1594. labels = c("0–1", "1–2", "2–3", "3–4", "4–5", "5–6", ">6"),
  1595. right = FALSE,
  1596. include.lowest = TRUE
  1597. )
  1598. )
  1599. return(df)
  1600. }
  1601. run_slingshot <- function(
  1602. obj,
  1603. cluster_col = "ann_level_2",
  1604. reduction = "pca",
  1605. n_pcs = NULL,
  1606. start_clust = "OHBC",
  1607. end_clusts = NULL,
  1608. approx_points = 100,
  1609. verbose = TRUE
  1610. ) {
  1611. suppressPackageStartupMessages({
  1612. library(slingshot)
  1613. library(Seurat)
  1614. library(dplyr)
  1615. library(mgcv)
  1616. library(scales)
  1617. })
  1618. if (verbose) cat("Preparing clustering info...\n")
  1619. Idents(obj) <- obj[[cluster_col, drop = TRUE]]
  1620. clusters <- factor(as.character(Idents(obj)))
  1621. if (verbose) cat(paste0("Using ", reduction, " reduction...\n"))
  1622. emb <- Embeddings(obj, reduction)
  1623. if (is.null(n_pcs)) {
  1624. if (verbose) cat("Auto-detecting PCs (all available)...\n")
  1625. pca_mat <- emb
  1626. } else {
  1627. pca_mat <- emb[, seq_len(n_pcs), drop = FALSE]
  1628. }
  1629. if (verbose) cat("Running Slingshot...\n")
  1630. sds <- slingshot(
  1631. pca_mat,
  1632. clusterLabels = clusters,
  1633. start.clus = start_clust,
  1634. end.clus = end_clusts,
  1635. approx_points = approx_points
  1636. )
  1637. if (verbose) cat("Extracting pseudotime...\n")
  1638. pt <- slingPseudotime(sds)
  1639. colnames(pt) <- paste0("Lineage", seq_len(ncol(pt)))
  1640. # add pseudotime back into metadata
  1641. pt_df <- as.data.frame(pt)
  1642. obj <- AddMetaData(obj, metadata = pt_df)
  1643. if (verbose) {
  1644. cat("Done!\n")
  1645. cat("Lineages detected: ", paste(colnames(pt), collapse = ", "),"\n")
  1646. cat("Cells with pseudotime per lineage:\n")
  1647. print(colSums(!is.na(pt)))
  1648. }
  1649. return(list(
  1650. seurat = obj,
  1651. sds = sds,
  1652. pseudotime = pt
  1653. ))
  1654. }
  1655. #' Split a Seurat object by metadata and fix matrix names
  1656. #'
  1657. #' @param object A Seurat object
  1658. #' @param split.by Metadata column name to split by
  1659. #' @return A named list of Seurat objects with @layers$counts fixed
  1660. #' @examples
  1661. #' seurat.list <- split_seurat_objects(merged, split.by = "BulkSample")
  1662. split_seurat <- function(object, split.by) {
  1663. require(Seurat)
  1664. objs <- Seurat::SplitObject(object, split.by = split.by)
  1665. objs <- lapply(objs, function(sobj) {
  1666. # Ensure @layers$counts exist
  1667. assay <- sobj[["RNA"]]
  1668. assay@layers$counts <- Seurat::GetAssayData(sobj, slot = "counts")
  1669. sobj[["RNA"]] <- assay
  1670. # Fix row/colnames
  1671. sobj <- fix_seurat_matrix_names(sobj)
  1672. return(sobj)
  1673. })
  1674. return(objs)
  1675. }
  1676. #' Add combined bulk/demux mapping to a Seurat object
  1677. #'
  1678. #' This function merges a combined cell-level mapping table (e.g., from souporcell, vireo, or scds)
  1679. #' into a Seurat object's metadata. Optionally, it can remove cells labeled as "doublet" or "unassigned".
  1680. #'
  1681. #' @param object A Seurat object.
  1682. #' @param combined_tsv Path to the combined mapping TSV file or a data.frame.
  1683. #' @param barcode_col Name of the barcode column in the TSV (default: "Barcode").
  1684. #' @param remove_doublets Logical, if TRUE removes cells labeled as "doublet" or "unassigned" in `BulkSample` (default: TRUE).
  1685. #' @return A Seurat object with updated metadata.
  1686. #' @examples
  1687. #' FN_S1256 <- add_bulk_mapping_to_seurat(FN_S1256, combined_tsv = "combined_results_with_bulk_mapping.tsv")
  1688. #' @export
  1689. add_bulk_mapping_to_seurat <- function(
  1690. object,
  1691. combined_tsv,
  1692. barcode_col = "Barcode",
  1693. remove_doublets = TRUE
  1694. ) {
  1695. library(Seurat)
  1696. library(dplyr)
  1697. # Load combined mapping
  1698. if (is.character(combined_tsv)) {
  1699. df <- read.delim(combined_tsv, stringsAsFactors = FALSE)
  1700. } else if (is.data.frame(combined_tsv)) {
  1701. df <- combined_tsv
  1702. } else {
  1703. stop("combined_tsv must be a file path or a data.frame")
  1704. }
  1705. # Ensure barcode column exists
  1706. if (!barcode_col %in% colnames(df)) stop(paste("Column", barcode_col, "not found in combined mapping"))
  1707. # Clean barcodes to match Seurat
  1708. df$Barcode_clean <- gsub("_1$", "", df[[barcode_col]])
  1709. rownames(df) <- df$Barcode_clean
  1710. # Keep only barcodes present in Seurat
  1711. common_cells <- intersect(colnames(object), rownames(df))
  1712. if (length(common_cells) == 0) stop("No overlapping barcodes between Seurat object and mapping")
  1713. df_sub <- df[common_cells, , drop = FALSE]
  1714. # Add metadata
  1715. object <- AddMetaData(object, df_sub)
  1716. # Remove doublets/unassigned if requested
  1717. if (remove_doublets && "BulkSample" %in% colnames([email hidden])) {
  1718. bad_labels <- c("doublet", "doublets", "Doublet", "Doublets",
  1719. "unassigned", "Unassigned")
  1720. current_idents <- Idents(object)
  1721. to_remove <- intersect(bad_labels, levels(current_idents))
  1722. if (length(to_remove) > 0) {
  1723. object <- subset(object, idents = to_remove, invert = TRUE)
  1724. }
  1725. }
  1726. return(object)
  1727. }
  1728. #' Compute gene-set dominance score per cell
  1729. #'
  1730. #' Computes a dominance score for a gene set in each cell based on the
  1731. #' relative expression of the top-expressed gene versus the second-highest
  1732. #' expressed gene. Optionally scales dominance by expression magnitude,
  1733. #' bins scores, assigns QC flags, and appends selected metadata for
  1734. #' downstream analysis.
  1735. #'
  1736. #' @param obj A \code{Seurat} object.
  1737. #' @param geneset Character vector of gene names defining the gene set.
  1738. #' @param assay Assay to use (default: \code{"RNA"}).
  1739. #' @param slot Expression slot to use (default: \code{"data"}).
  1740. #' @param epsilon Small constant to avoid division by zero.
  1741. #' @param scale.log Logical; whether to scale dominance by log-transformed
  1742. #' top gene expression.
  1743. #' @param exponent Numeric exponent applied to log-scaled expression.
  1744. #' @param bins Numeric vector defining bins for adjusted dominance scores.
  1745. #' @param bin.labels Optional character labels for dominance bins.
  1746. #' @param strong.dom.thresh Threshold above which a cell is flagged as
  1747. #' strongly dominant.
  1748. #' @param group.by Metadata column used for cell grouping
  1749. #' (e.g. \code{"ann2"}).
  1750. #' @param idents Character vector of identity values to retain;
  1751. #' all others are labeled \code{"Others"}.
  1752. #' @param add.meta Character vector of additional metadata columns
  1753. #' (e.g. \code{c("sample","stage","sex")}) to append to output.
  1754. #' @param verbose Logical; whether to print informative messages.
  1755. #'
  1756. #' @details
  1757. #' For each cell, the dominance score is calculated as:
  1758. #'
  1759. #' \deqn{(Top1 - Top2) / (Top1 + Top2 + epsilon)}
  1760. #'
  1761. #' If \code{scale.log = TRUE}, the score is multiplied by
  1762. #' \code{log1p(Top1)^exponent}.
  1763. #'
  1764. #' Only the metadata columns specified in \code{group.by} and
  1765. #' \code{add.meta} are joined, ensuring minimal memory overhead.
  1766. #'
  1767. #' @return A list with:
  1768. #' \itemize{
  1769. #' \item \code{df}: Data frame with per-cell dominance metrics,
  1770. #' bin assignments, QC flags, grouping identity, and selected metadata.
  1771. #' \item \code{genes_detected}: Character vector of detected genes from
  1772. #' \code{geneset}.
  1773. #' }
  1774. #'
  1775. #' @seealso \code{\link[Seurat]{GetAssayData}}
  1776. #'
  1777. #' @examples
  1778. #' \dontrun{
  1779. #' res <- domscore(
  1780. #' obj = object,
  1781. #' geneset = OR_genes,
  1782. #' group.by = "ann2",
  1783. #' idents = c("INP", "iOSN"),
  1784. #' add.meta = c("sample", "stage", "sex"),
  1785. #' bins = c(-Inf, 0.5, 1, 1.5, 2, Inf)
  1786. #' )
  1787. #'
  1788. #' head(res$df)
  1789. #' }
  1790. #'
  1791. #' @export
  1792. #'
  1793. domscore <- function(
  1794. obj,
  1795. geneset = NULL,
  1796. assay = "RNA",
  1797. slot = "data",
  1798. epsilon = 1e-9,
  1799. scale.log = TRUE,
  1800. exponent = 1,
  1801. bins = c(-Inf, 0.1, 0.5, 1, 2, Inf),
  1802. bin.labels = NULL,
  1803. strong.dom.thresh = 1.5,
  1804. group.by = NULL,
  1805. idents = NULL,
  1806. add.meta = NULL,
  1807. verbose = TRUE
  1808. ) {
  1809. require(Seurat)
  1810. require(Matrix)
  1811. require(dplyr)
  1812. stopifnot(inherits(obj, "Seurat"))
  1813. ## Gene detection
  1814. all_genes <- rownames(obj[[assay]])
  1815. detected_genes <- intersect(unique(geneset), all_genes)
  1816. if (length(detected_genes) == 0)
  1817. stop("No genes from geneset found in Seurat object.")
  1818. expr_mat <- as.matrix(
  1819. GetAssayData(obj, assay = assay, leyer = slot)[
  1820. detected_genes, , drop = FALSE
  1821. ]
  1822. )
  1823. ## Top1 / Top2 per cell
  1824. compute_top2_cell <- function(v) {
  1825. nz <- which(v != 0)
  1826. if (length(nz) == 0) return(c(top1 = 0, top2 = 0))
  1827. if (length(nz) == 1) return(c(top1 = v[nz], top2 = 0))
  1828. ord <- order(v, decreasing = TRUE)
  1829. c(top1 = v[ord[1]], top2 = v[ord[2]])
  1830. }
  1831. top2_mat <- apply(expr_mat, 2, compute_top2_cell)
  1832. if (is.null(dim(top2_mat)))
  1833. top2_mat <- matrix(top2_mat, nrow = 2)
  1834. top1_vec <- top2_mat[1, ]
  1835. top2_vec <- top2_mat[2, ]
  1836. dominance <- (top1_vec - top2_vec) /
  1837. (top1_vec + top2_vec + epsilon)
  1838. adjust_score <- if (scale.log) {
  1839. dominance * (log1p(top1_vec) ^ exponent)
  1840. } else {
  1841. dominance
  1842. }
  1843. ## Top gene & counts
  1844. get_top_gene <- function(v) {
  1845. if (all(v == 0)) return(NA_character_)
  1846. rownames(expr_mat)[which.max(v)]
  1847. }
  1848. top_gene_vec <- apply(expr_mat, 2, get_top_gene)
  1849. n_genes_expr <- Matrix::colSums(expr_mat > 0)
  1850. ## Base dataframe
  1851. df <- data.frame(
  1852. cell = colnames(expr_mat),
  1853. top_gene = top_gene_vec,
  1854. top_expr = as.numeric(top1_vec),
  1855. second_expr = as.numeric(top2_vec),
  1856. dominance = as.numeric(dominance),
  1857. adjust_score = as.numeric(adjust_score),
  1858. n_genes_expr = as.integer(n_genes_expr),
  1859. stringsAsFactors = FALSE
  1860. )
  1861. ## Selective metadata join
  1862. meta <- [email hidden]
  1863. meta$cell <- rownames(meta)
  1864. cols_to_add <- unique(c(group.by, add.meta))
  1865. cols_to_add <- intersect(cols_to_add, colnames(meta))
  1866. if (length(cols_to_add) > 0) {
  1867. df <- left_join(
  1868. df,
  1869. meta[, c("cell", cols_to_add), drop = FALSE],
  1870. by = "cell"
  1871. )
  1872. } else if (verbose) {
  1873. message("No metadata columns added.")
  1874. }
  1875. ## Cell grouping
  1876. ## Cell grouping (SAFE)
  1877. if (!is.null(group.by) && group.by %in% colnames(df)) {
  1878. group_vals <- as.character(df[[group.by]])
  1879. if (!is.null(idents)) {
  1880. df$ident <- ifelse(
  1881. group_vals %in% idents,
  1882. group_vals,
  1883. "Others"
  1884. )
  1885. } else {
  1886. df$ident <- group_vals
  1887. }
  1888. } else {
  1889. df$ident <- "Others"
  1890. if (verbose)
  1891. warning("group.by not found; all cells set to 'Others'.")
  1892. }
  1893. ## Binning
  1894. if (is.null(bin.labels)) {
  1895. bin.labels <- sapply(seq_len(length(bins) - 1), function(i) {
  1896. lb <- bins[i]; ub <- bins[i + 1]
  1897. if (is.finite(lb) && is.finite(ub)) paste0(lb, "-", ub)
  1898. else if (!is.finite(lb)) paste0("<", ub)
  1899. else paste0(">=", lb)
  1900. })
  1901. }
  1902. df$adjusted_bin <- cut(
  1903. df$adjust_score,
  1904. breaks = bins,
  1905. labels = bin.labels,
  1906. include.lowest = TRUE,
  1907. right = FALSE
  1908. )
  1909. ## QC flags
  1910. df$qc_flag <- "ok"
  1911. df$qc_flag[df$n_genes_expressed == 0] <- "no_gene"
  1912. df$qc_flag[df$top_expr == 0] <- "no_top_expr"
  1913. df$qc_flag[df$adjust_score >= strong.dom.thresh] <- "strong_dominant"
  1914. ## Output
  1915. list(df = df, genes_detected = detected_genes)
  1916. }
  1917. #' Find Marker Genes in a Seurat Object
  1918. #' This function identifies marker genes for specified clusters in a Seurat object
  1919. #' using differential expression testing within each cluster.
  1920. #' Run Seurat::FindMarkers across clusters with multiple tests + consensus support + visualization
  1921. #'
  1922. #' @param object Seurat object
  1923. #' @param group.by Metadata column for clustering/identity
  1924. #' @param test.use Differential test(s) ("wilcox", "bimod", "roc", "t",
  1925. #' "negbinom", "poisson", "LR", "MAST" or "all")
  1926. #' @param only.pos Keep only positive markers (default TRUE)
  1927. #' @param min.pct Minimum fraction of cells expressing a gene (default 0.25)
  1928. #' @param min.diff.pct Minimum difference in pct (default -Inf)
  1929. #' @param man.logfc.threshold LogFC threshold (default 0.25)
  1930. #' @param clusters.to.exclude Clusters to skip (default none)
  1931. #' @param max.cells.per.ident Downsample max cells per cluster (default Inf)
  1932. #' @param consensus Logical, if TRUE build consensus markers across tests
  1933. #' @param consensus_min_tests Integer, minimum number of tests a gene must be significant in
  1934. #' @param alpha Adjusted p-value threshold for significance (default 0.05)
  1935. #' @param return_both Logical, return both raw + consensus results (default FALSE)
  1936. #' @param plot_type "none", "bar", or "upset" (default "none")
  1937. #' @param plot_cluster Cluster name for upset plot (default first cluster)
  1938. #' @param ... Passed to Seurat::FindMarkers
  1939. #'
  1940. #' @return Data frame(s) of markers, optionally with ggplot object
  1941. #' @export
  1942. cellmarker <- function(object,
  1943. group.by,
  1944. assay = "RNA",
  1945. features = NULL,
  1946. test.use = 'wilcox',
  1947. only.pos = TRUE,
  1948. min.pct = 0.01,
  1949. min.diff.pct = -Inf,
  1950. logfc.threshold = 0.1,
  1951. clusters.to.exclude = c(),
  1952. max.cells.per.ident = Inf,
  1953. latent.vars = NULL,
  1954. consensus = FALSE,
  1955. consensus_min_tests = 2,
  1956. alpha = 0.05,
  1957. return_both = FALSE,
  1958. plot_type = c("none","bar","upset"),
  1959. plot_cluster = NULL,
  1960. ...) {
  1961. requireNamespace("ggplot2")
  1962. if ("upset" %in% plot_type) requireNamespace("UpSetR")
  1963. if (!inherits(object, "Seurat")) stop("The provided object is not a Seurat object.")
  1964. if (!missing(group.by)) object <- Seurat::SetIdent(object = object, value = group.by)
  1965. valid_tests <- c("wilcox", "bimod", "roc", "t",
  1966. "negbinom", "poisson", "LR", "MAST")
  1967. if (identical(test.use, "all")) {
  1968. tests_to_run <- valid_tests
  1969. } else {
  1970. if (!all(test.use %in% valid_tests)) stop(paste("Invalid test.use. Choose from:", paste(valid_tests, collapse = ", ")))
  1971. tests_to_run <- test.use
  1972. }
  1973. clusters.to.test <- sort(unique([email hidden]))
  1974. clusters.to.test <- setdiff(clusters.to.test, clusters.to.exclude)
  1975. results_all <- list()
  1976. for (test in tests_to_run) {
  1977. message("=== Running test: ", test, " ===")
  1978. joined <- data.frame()
  1979. for (i in seq_along(clusters.to.test)) {
  1980. cluster <- clusters.to.test[i]
  1981. message(sprintf("[ %d / %d ] %s - cluster: %s ...",
  1982. i, length(clusters.to.test), test, cluster))
  1983. markers <- Seurat::FindMarkers(
  1984. object,
  1985. ident.1 = cluster,
  1986. ident.2 = NULL,
  1987. features = features,
  1988. only.pos = only.pos,
  1989. assay = assay,
  1990. slot = "data",
  1991. test.use = test,
  1992. min.pct = min.pct,
  1993. min.cells.group = 3,
  1994. min.diff.pct = min.diff.pct,
  1995. logfc.threshold = logfc.threshold,
  1996. max.cells.per.ident = max.cells.per.ident,
  1997. latent.vars = latent.vars,
  1998. ...
  1999. )
  2000. if (!is.null(markers) && nrow(markers) > 0) {
  2001. markers$cluster <- cluster
  2002. markers$gene <- rownames(markers)
  2003. markers$test.use <- test
  2004. rownames(markers) <- NULL
  2005. joined <- rbind(joined, markers)
  2006. } else {
  2007. message(sprintf("Skipping cluster '%s': no markers found.", cluster))
  2008. }
  2009. }
  2010. results_all[[test]] <- joined
  2011. }
  2012. all_results <- dplyr::bind_rows(results_all)
  2013. #- Single test shortcut-
  2014. if (length(tests_to_run) == 1 && !consensus && !return_both) {
  2015. return(all_results)
  2016. }
  2017. #- Consensus / visualization-
  2018. consensus_df <- NULL
  2019. if (consensus || return_both) {
  2020. consensus_df <- all_results %>%
  2021. dplyr::filter(p_val_adj <= alpha) %>%
  2022. dplyr::group_by(cluster, gene) %>%
  2023. dplyr::summarise(
  2024. n_tests = dplyr::n_distinct(test.use),
  2025. mean_logFC = mean(avg_log2FC, na.rm = TRUE),
  2026. min_p_val_adj = min(p_val_adj, na.rm = TRUE),
  2027. .groups = "drop"
  2028. ) %>%
  2029. dplyr::filter(n_tests >= consensus_min_tests)
  2030. }
  2031. plot_type <- match.arg(plot_type)
  2032. plt <- NULL
  2033. if (plot_type == "bar") {
  2034. marker_counts <- all_results %>%
  2035. dplyr::filter(p_val_adj <= alpha) %>%
  2036. dplyr::group_by(cluster, test.use) %>%
  2037. dplyr::summarise(n_genes = dplyr::n(), .groups = "drop")
  2038. plt <- ggplot2::ggplot(marker_counts,
  2039. ggplot2::aes(x = cluster, y = n_genes, fill = test.use)) +
  2040. ggplot2::geom_bar(stat="identity", position="dodge") +
  2041. plot_theme(theme.type = "classic", x.angle = 45) +
  2042. ggplot2::labs(title="Significant markers per test per cluster",
  2043. y="Number of markers", x="Cluster")
  2044. }
  2045. if (plot_type == "upset") {
  2046. if (is.null(plot_cluster)) plot_cluster <- clusters.to.test[1]
  2047. df_upset <- all_results %>%
  2048. dplyr::filter(cluster == plot_cluster, p_val_adj <= alpha) %>%
  2049. dplyr::select(gene, test.use) %>%
  2050. dplyr::distinct()
  2051. mat <- table(df_upset$gene, df_upset$test.use) > 0
  2052. plt <- UpSetR::upset(UpSetR::fromMatrix(mat),
  2053. mainbar.y.label = paste("Overlap of markers -", plot_cluster))
  2054. }
  2055. if (return_both) return(list(raw = all_results, consensus = consensus_df, plot = plt))
  2056. if (consensus) return(list(consensus = consensus_df, plot = plt))
  2057. return(list(raw = all_results, plot = plt))
  2058. }
  2059. #' Identify Temporally Regulated Genes Along Pseudotime
  2060. #'
  2061. #' This function identifies genes whose expression varies significantly
  2062. #' along pseudotime within selected cell types. It supports Slingshot
  2063. #' pseudotime objects (`sds_obj`) or a user-provided pseudotime matrix (`pt_matrix`).
  2064. #' Genes are filtered by variability, minimum expression, pseudotime range,
  2065. #' and minimal dynamic range (logFC). Candidate genes are tested via GAM
  2066. #' (`mgcv::gam`) for significant smooth changes along pseudotime.
  2067. #'
  2068. #' @param object Seurat object containing expression data.
  2069. #' @param sds_obj Optional \code{SlingshotDataSet} object with pseudotime estimates.
  2070. #' @param pt_matrix Optional numeric matrix of pseudotime values
  2071. #' (cells × lineages). Used if \code{sds_obj} is not provided.
  2072. #' @param annotation_level Metadata column name specifying cell type labels
  2073. #' (default: `"ann_level_2"`).
  2074. #' @param selected_celltypes Vector of cell types to include (default:
  2075. #' `c("OHBC","GBC","INP","iOSN")`).
  2076. #' @param assay Assay name in Seurat object (default: `"RNA"`).
  2077. #' @param expr_slot Expression slot to use (default: `"data"`).
  2078. #' @param n_var_genes Number of highly variable genes to preselect for testing.
  2079. #' @param min_cells_expressed Minimum number of cells a gene must be expressed in.
  2080. #' @param min_pct_cells Minimum fraction of cells expressing a gene.
  2081. #' @param min_pt_range Minimum pseudotime range over which a gene is expressed.
  2082. #' @param min_logFC Minimum dynamic range (max − min expression) required.
  2083. #' @param gam_k Number of basis functions used in GAM smoothing (default: 6).
  2084. #' @param top_n Optional integer. If provided, extracts the top-n significant genes for heatmap use.
  2085. #' @param qval_cutoff Adjusted p-value (FDR) threshold for significance.
  2086. #' @param verbose Logical; print progress messages.
  2087. #'
  2088. #' @return A list containing:
  2089. #' \describe{
  2090. #' \item{lineage_results}{A list of data frames per lineage,
  2091. #' containing p-values, q-values, and significant genes.}
  2092. #' \item{heatmap_matrices}{A list of matrices per lineage:
  2093. #' raw expression, scaled expression (all significant genes),
  2094. #' scaled expression for top N genes, and ordered cell order.}
  2095. #' \item{pt_used}{The pseudotime matrix used for analysis.}
  2096. #' }
  2097. #'
  2098. #' @details
  2099. #' Filtering steps:
  2100. #' \enumerate{
  2101. #' \item Highest-variable genes preselected.
  2102. #' \item Genes must be expressed in sufficient cells.
  2103. #' \item Genes must span a minimum pseudotime window.
  2104. #' \item Genes must show sufficient dynamic range (logFC).
  2105. #' }
  2106. #'
  2107. #' GAM testing is performed using: \code{y ~ s(pt, k = gam_k)}.
  2108. #'
  2109. #' @import mgcv Seurat dplyr matrixStats tibble
  2110. #'
  2111. #' @examples
  2112. #' \dontrun{
  2113. #' result <- identify_temporal_genes(
  2114. #' object = object,
  2115. #' sds_obj = slingshot_obj,
  2116. #' selected_celltypes = c("iOSN")
  2117. #' )
  2118. #' }
  2119. #'
  2120. #' @export
  2121. #'
  2122. identify_temporal_genes <- function(object,
  2123. sds_obj = NULL,
  2124. pt_matrix = NULL,
  2125. annotation_level = "ann_level_2",
  2126. selected_celltypes = c("OHBC","GBC","INP","iOSN"),
  2127. assay = "RNA",
  2128. expr_slot = "data",
  2129. n_var_genes = 500,
  2130. min_cells_expressed = 10,
  2131. min_pct_cells = 0.05,
  2132. min_pt_range = 0.15,
  2133. min_logFC = 0.5,
  2134. gam_k = 6,
  2135. top_n = NULL, # optional
  2136. qval_cutoff = 0.05,
  2137. verbose = TRUE) {
  2138. require(mgcv); require(Seurat); require(dplyr); require(matrixStats); require(tibble)
  2139. if (is.null(sds_obj) && is.null(pt_matrix)) stop("Provide sds_obj or pt_matrix")
  2140. cells_sel <- WhichCells(object, idents = selected_celltypes)
  2141. pt_full <- if(is.null(pt_matrix)) slingPseudotime(sds_obj) else pt_matrix
  2142. common_cells <- intersect(rownames(pt_full), colnames(object))
  2143. cells_use <- intersect(common_cells, cells_sel)
  2144. pt_full <- pt_full[cells_use,, drop=FALSE]
  2145. expr_all <- as.matrix(GetAssayData(object, assay=assay, slot=expr_slot))[,cells_use, drop = FALSE]
  2146. var_feats <- rownames(expr_all)[order(rowVars(expr_all), decreasing=TRUE)][1:min(n_var_genes, nrow(expr_all))]
  2147. lineage_results <- list()
  2148. heatmap_matrices <- list()
  2149. for(li in seq_len(ncol(pt_full))){
  2150. lineage_name <- colnames(pt_full)[li]
  2151. if(verbose) message("Processing ", lineage_name, " ...")
  2152. pts <- pt_full[, li]
  2153. valid_cells <- names(pts)[!is.na(pts)]
  2154. pts <- pts[valid_cells]
  2155. expr <- expr_all[var_feats, valid_cells, drop = FALSE]
  2156. # filtering
  2157. expressed_counts <- rowSums(expr > 0)
  2158. cells_min <- max(min_cells_expressed, ceiling(min_pct_cells * length(valid_cells)))
  2159. keep1 <- names(expressed_counts)[expressed_counts >= cells_min]
  2160. if(length(keep1) == 0) {
  2161. if(verbose) message(" No genes after expression filter; skipping")
  2162. lineage_results[[lineage_name]] <- tibble()
  2163. heatmap_matrices[[lineage_name]] <- list(expr_raw = NULL, expr_scaled_top = NULL, ordered_cells = names(sort(pts)), top_genes = NULL)
  2164. next
  2165. }
  2166. expr_pt_ranges <- t(apply(expr[keep1, , drop=FALSE] > 0, 1, function(x) {
  2167. r <- range(pts[x], na.rm=TRUE)
  2168. if (any(is.na(r))) return(c(NA, NA))
  2169. r
  2170. }))
  2171. expr_pt_range <- expr_pt_ranges[,2] - expr_pt_ranges[,1]
  2172. keep2 <- names(expr_pt_range)[!is.na(expr_pt_range) & expr_pt_range >= min_pt_range]
  2173. if(length(keep2) == 0) {
  2174. if(verbose) message(" No genes after pseudotime-range filter; skipping")
  2175. lineage_results[[lineage_name]] <- tibble()
  2176. heatmap_matrices[[lineage_name]] <- list(expr_raw = NULL, expr_scaled_top = NULL, ordered_cells = names(sort(pts)), top_genes = NULL)
  2177. next
  2178. }
  2179. logfc <- matrixStats::rowMaxs(expr[keep2, , drop=FALSE]) - matrixStats::rowMins(expr[keep2, , drop=FALSE])
  2180. keep3 <- names(logfc)[!is.na(logfc) & logfc >= min_logFC]
  2181. if(length(keep3) == 0) {
  2182. if(verbose) message(" No genes after logFC filter; skipping")
  2183. lineage_results[[lineage_name]] <- tibble()
  2184. heatmap_matrices[[lineage_name]] <- list(expr_raw = NULL, expr_scaled_top = NULL, ordered_cells = names(sort(pts)), top_genes = NULL)
  2185. next
  2186. }
  2187. if(verbose) message(" Candidate genes: ", length(keep3))
  2188. # GAM testing
  2189. pvals <- setNames(rep(NA_real_, length(keep3)), keep3)
  2190. for(g in keep3){
  2191. d <- data.frame(y = as.numeric(expr[g, ]), pt = as.numeric(pts))
  2192. if(sd(d$y, na.rm = TRUE) == 0) next
  2193. m <- tryCatch(mgcv::gam(y ~ s(pt, k = gam_k), data = d), error = function(e) NULL)
  2194. if(!is.null(m)){
  2195. sst <- summary(m)$s.table
  2196. if(!is.null(sst)) {
  2197. pvals[g] <- if("p-value" %in% colnames(sst)) sst[1, "p-value"] else sst[1, ncol(sst)]
  2198. }
  2199. }
  2200. }
  2201. res_df <- tibble(id = names(pvals), raw_p = as.numeric(pvals)) %>%
  2202. filter(!is.na(raw_p)) %>%
  2203. mutate(qval = p.adjust(raw_p, method = "fdr")) %>%
  2204. arrange(qval)
  2205. # all significant genes (full table)
  2206. sig_df <- res_df %>% filter(qval <= qval_cutoff)
  2207. if(verbose) message(" Significant genes: ", nrow(sig_df))
  2208. ord_cells <- names(sort(pts))
  2209. # Always store expr_raw for the significant genes (could be zero rows)
  2210. if(nrow(sig_df) > 0) {
  2211. expr_raw_sig <- as.matrix(expr[sig_df$id, ord_cells, drop = FALSE])
  2212. # scale for inspection (not required for plotting; plot function may re-scale or smooth)
  2213. expr_scaled_all_sig <- t(scale(t(expr_raw_sig)))
  2214. expr_scaled_all_sig[is.na(expr_scaled_all_sig)] <- 0
  2215. } else {
  2216. expr_raw_sig <- NULL
  2217. expr_scaled_all_sig <- NULL
  2218. }
  2219. # If user requested top_n, compute expr_scaled_top and top_genes
  2220. if(!is.null(top_n) && nrow(sig_df) > 0) {
  2221. top_genes <- head(sig_df$id, top_n)
  2222. expr_sub <- as.matrix(expr[top_genes, ord_cells, drop = FALSE])
  2223. expr_scaled_top <- t(scale(t(expr_sub))); expr_scaled_top[is.na(expr_scaled_top)] <- 0
  2224. } else {
  2225. top_genes <- NULL
  2226. expr_scaled_top <- NULL
  2227. }
  2228. lineage_results[[lineage_name]] <- sig_df
  2229. heatmap_matrices[[lineage_name]] <- list(
  2230. expr_raw = expr_raw_sig, # raw expr for *all* significant genes
  2231. expr_scaled_top = expr_scaled_top, # scaled expr matrix for top_n (NULL if not requested)
  2232. expr_scaled_all = expr_scaled_all_sig, # scaled expr matrix for all sig genes (may be NULL)
  2233. ordered_cells = ord_cells,
  2234. top_genes = top_genes
  2235. )
  2236. }
  2237. return(list(
  2238. lineage_results = lineage_results,
  2239. heatmap_matrices = heatmap_matrices,
  2240. pt_used = pt_full
  2241. ))
  2242. }
  2243. plot_theme <- function(
  2244. theme.style = c("minimal","classic","bw","test","void","dirty","gray"),
  2245. font.size = 8,
  2246. xy.val = TRUE,
  2247. x.angle = 0,
  2248. hjust = NULL,
  2249. vjust = NULL,
  2250. xlab = TRUE,
  2251. ylab = TRUE,
  2252. xy.lab = TRUE,
  2253. facet.face = "bold",
  2254. ttl.face = "bold",
  2255. txt.face = c("plain","italic","bold"),
  2256. ttl.pos = c("center","left","right"),
  2257. x.ttl = TRUE,
  2258. y.ttl = TRUE,
  2259. ticks = NULL,
  2260. line = NULL,
  2261. border = NULL,
  2262. grid.major = NULL,
  2263. grid.minor = NULL,
  2264. panel.fill = "white",
  2265. facet.bg = TRUE,
  2266. mode = c("light","dark"),
  2267. leg.pos = "right",
  2268. leg.dir = "vertical",
  2269. leg.size = 8,
  2270. leg.ttl = 8,
  2271. leg.ttl.size = 8,
  2272. leg.just = "center",
  2273. leg.ttl.text = NULL,
  2274. ...
  2275. ) {
  2276. require(ggplot2)
  2277. theme.style <- match.arg(theme.style)
  2278. ttl.pos <- match.arg(ttl.pos)
  2279. txt.face <- match.arg(txt.face)
  2280. mode <- match.arg(mode)
  2281. # Canonical line width (THIS FIXES YOUR PROBLEM)
  2282. lw <- 0.3
  2283. if (is.null(line)) {
  2284. line <- theme.style == "classic"
  2285. }
  2286. # Colors
  2287. if (mode == "light") {
  2288. col.txt <- "#1A1A1A"
  2289. col.grid <- "#D9D9D9"
  2290. col.panel <- panel.fill
  2291. col.strip <- "#EFEFEF"
  2292. } else {
  2293. col.txt <- "#DDDDDD"
  2294. col.grid <- "#444444"
  2295. col.panel <- "#1E1E1E"
  2296. col.strip <- "#383838"
  2297. }
  2298. # Automatic x-label alignment
  2299. if (is.null(hjust) || is.null(vjust)) {
  2300. if (x.angle == 0) { hjust <- .5; vjust <- 1 }
  2301. else if (x.angle == 45) { hjust <- 1; vjust <- 1 }
  2302. else if (x.angle == 90) { hjust <- 1; vjust <- .5 }
  2303. else if (x.angle == 270){ hjust <- 0; vjust <- .5 }
  2304. else { hjust <- 1; vjust <- 1 }
  2305. }
  2306. ttl.pos <- switch(ttl.pos, left = 0, center = .5, right = 1)
  2307. # Base theme components
  2308. base <- theme(
  2309. text = element_text(color = col.txt, size = font.size, family = "Helvetica"),
  2310. axis.text.x = element_text(color = col.txt, size = font.size),
  2311. axis.text.y = element_text(color = col.txt, size = font.size),
  2312. axis.title = element_text(size = font.size),
  2313. plot.title = element_text(
  2314. hjust = ttl.pos, face = ttl.face,
  2315. size = font.size + 1, color = col.txt
  2316. ),
  2317. strip.text = element_text(face = facet.face, color = col.txt),
  2318. legend.title = element_text(size = leg.ttl.size, face = "bold"),
  2319. legend.text = element_text(size = leg.size),
  2320. legend.position = leg.pos,
  2321. legend.key.height = unit(.4, "cm"),
  2322. legend.key.width = unit(.4, "cm"),
  2323. legend.background = element_blank(),
  2324. legend.box.background = element_blank(),
  2325. legend.key = element_blank(),
  2326. legend.box = "vertical",
  2327. legend.spacing.y = unit(0.05, "cm"),
  2328. legend.margin = margin(1,1,1,1),
  2329. ...
  2330. )
  2331. # Preset themes
  2332. preset <- switch(
  2333. theme.style,
  2334. minimal = theme_minimal(base_size = font.size),
  2335. classic = theme_classic(base_size = font.size),
  2336. bw = theme_bw(base_size = font.size),
  2337. test = theme_test(base_size = font.size),
  2338. void = theme_void(base_size = font.size),
  2339. dirty = theme_minimal(base_size = font.size) +
  2340. theme(
  2341. panel.grid = element_blank(),
  2342. panel.border = element_blank(),
  2343. axis.ticks = element_blank()
  2344. ),
  2345. gray = theme_gray(base_size = font.size) +
  2346. theme(
  2347. panel.background = element_rect(fill = "#EDEDED", color = NA),
  2348. panel.grid.major = element_line(color = "#CCCCCC", linewidth = lw),
  2349. panel.grid.minor = element_line(color = "#DDDDDD", linewidth = lw/2)
  2350. )
  2351. )
  2352. th <- preset + base
  2353. # Normalize panel borders for border-based themes
  2354. if (theme.style %in% c("bw", "test", "gray")) {
  2355. th <- th + theme(
  2356. panel.border = element_rect(
  2357. linewidth = lw,
  2358. color = col.txt,
  2359. fill = NA
  2360. ),
  2361. axis.line = element_blank() # ⬅️ critical
  2362. )
  2363. }
  2364. # Axis lines (classic-style)
  2365. if (line && theme.style == "classic") {
  2366. th <- th + theme(
  2367. axis.line.x = element_line(color = col.txt, linewidth = lw),
  2368. axis.line.y = element_line(color = col.txt, linewidth = lw)
  2369. )
  2370. } else {
  2371. th <- th + theme(axis.line = element_blank())
  2372. }
  2373. # X-axis angle
  2374. if (xy.val && xlab) {
  2375. th <- th + theme(
  2376. axis.text.x = element_text(angle = x.angle, hjust = hjust, vjust = vjust)
  2377. )
  2378. }
  2379. # Label / tick / grid overrides
  2380. if (!xy.lab) th <- th + theme(axis.text = element_blank(), axis.ticks = element_blank())
  2381. if (!xlab) th <- th + theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())
  2382. if (!ylab) th <- th + theme(axis.text.y = element_blank(), axis.ticks.y = element_blank())
  2383. if (!x.ttl) th <- th + theme(axis.title.x = element_blank())
  2384. if (!y.ttl) th <- th + theme(axis.title.y = element_blank())
  2385. if (!is.null(ticks)) {
  2386. th <- th + if (ticks)
  2387. theme(axis.ticks = element_line(color = col.txt, linewidth = lw))
  2388. else
  2389. theme(axis.ticks = element_blank())
  2390. }
  2391. if (!is.null(border)) {
  2392. th <- th + if (border)
  2393. theme(panel.border = element_rect(color = col.grid, fill = NA, linewidth = lw))
  2394. else
  2395. theme(panel.border = element_blank())
  2396. }
  2397. if (!is.null(grid.major)) {
  2398. th <- th + if (grid.major)
  2399. theme(panel.grid.major = element_line(color = col.grid, linewidth = lw))
  2400. else
  2401. theme(panel.grid.major = element_blank())
  2402. }
  2403. if (!is.null(grid.minor)) {
  2404. th <- th + if (grid.minor)
  2405. theme(panel.grid.minor = element_line(color = col.grid, linewidth = lw/2))
  2406. else
  2407. theme(panel.grid.minor = element_blank())
  2408. }
  2409. if (!facet.bg) {
  2410. th <- th + theme(strip.background = element_blank())
  2411. }
  2412. if (!is.null(leg.ttl.text)) {
  2413. th <- th + labs(color = leg.ttl.text)
  2414. }
  2415. # Void cleanup
  2416. # Void cleanup
  2417. if (theme.style == "void") {
  2418. th <- th + theme(
  2419. axis.text.x = element_blank(),
  2420. axis.text.y = element_blank(),
  2421. axis.ticks = element_blank(),
  2422. axis.title.x = element_blank(),
  2423. axis.title.y = element_blank(),
  2424. axis.line = element_blank(),
  2425. panel.grid = element_blank(),
  2426. panel.border = element_blank(),
  2427. strip.text = element_blank(),
  2428. strip.background = element_blank()
  2429. )
  2430. line <- FALSE
  2431. ticks <- FALSE
  2432. border <- FALSE
  2433. grid.major <- FALSE
  2434. grid.minor <- FALSE
  2435. facet.bg <- FALSE
  2436. }
  2437. # Dirty theme
  2438. if (theme.style == "dirty") {
  2439. line <- TRUE
  2440. ticks <- FALSE
  2441. border <- FALSE
  2442. grid.major <- FALSE
  2443. grid.minor <- FALSE
  2444. facet.bg <- FALSE
  2445. }
  2446. th
  2447. }
  2448. #' Generate a Color Palette with Presets and Interpolation
  2449. #'
  2450. #' This function generates a vector of colors based on predefined palette presets
  2451. #' or a user-supplied set of base colors. Colors can be interpolated in multiple
  2452. #' color spaces and adjusted for saturation, lightness, and vividness.
  2453. #'
  2454. #' @param n Integer. Number of colors to generate.
  2455. #' @param preset Character. One of \code{"bright"}, \code{"pastel"}, \code{"warm"},
  2456. #' \code{"cool"}, \code{"contrast"}, \code{"earth"}, \code{"base"}, or \code{"custom"}.
  2457. #' If \code{"custom"} is chosen, \code{base_colors} must be supplied.
  2458. #' @param base_colors Character vector of HEX color codes. Required if
  2459. #' \code{preset = "custom"}.
  2460. #' @param space Character. Color interpolation space. One of \code{"Lab"},
  2461. #' \code{"rgb"}, or \code{"HCL"}.
  2462. #' @param oversample_factor Numeric. Factor by which to oversample colors before filtering.
  2463. #' Larger values create smoother gradients. Default is \code{1.3}.
  2464. #' @param remove_gray Logical. If \code{TRUE} (default), removes grayish colors
  2465. #' (low saturation).
  2466. #' @param reverse Logical. If \code{TRUE}, reverses the order of colors.
  2467. #' @param adjust_saturation Numeric multiplier for color saturation (chroma) in HCL space.
  2468. #' Default is \code{1} (no change).
  2469. #' @param adjust_lightness Numeric multiplier for lightness in HCL space.
  2470. #' Default is \code{1} (no change).
  2471. #'
  2472. #' @details
  2473. #' This function provides flexibility for both discrete and continuous color needs.
  2474. #' If the number of requested colors (\code{n}) is greater than the number of colors
  2475. #' in the base palette, the palette is interpolated in the chosen color space.
  2476. #'
  2477. #' The \code{remove_gray} option filters out low-chroma colors that appear grayish.
  2478. #'
  2479. #' @return A character vector of HEX color codes.
  2480. #'
  2481. #' @examples
  2482. #' # Get 5 bright colors
  2483. #' custom_palette(5, preset = "bright", space = "Lab")
  2484. #'
  2485. #' # Get 15 colors from the custom "base" preset
  2486. #' custom_palette(15, preset = "base", space = "HCL")
  2487. #'
  2488. #' # Use a fully custom palette
  2489. #' my_cols <- c("#123456", "#abcdef", "#ff0000")
  2490. #' custom_palette(10, preset = "custom", base_colors = my_cols)
  2491. #'
  2492. #' @import colorspace
  2493. #' @export
  2494. custom_palette <- function(n,
  2495. preset = c("base","bright", "pastel", "warm", "cool", "contrast", "earth", "custom"),
  2496. base_colors = NULL,
  2497. space = c("Lab", "rgb", "HCL"),
  2498. oversample_factor = 1.3,
  2499. remove_gray = TRUE,
  2500. reverse = FALSE,
  2501. adjust_saturation = 1,
  2502. adjust_lightness = 1) {
  2503. library(colorspace)
  2504. # Presets
  2505. presets <- list(
  2506. bright = c("#E41A1C", "#377EB8", "#4DAF4A", "#984EA3", "#FF7F00",
  2507. "#FFFF33", "#A65628", "#F781BF", "#999999"),
  2508. pastel = c("#FDB462", "#B3DE69", "#BC80BD", "#CCEBC5", "#FFED6F",
  2509. "#FB9A99", "#B2DF8A", "#CAB2D6", "#FFFFB3"),
  2510. warm = c("#8C510A", "#BF812D", "#DFC27D", "#F6E8C3", "#FDD49E",
  2511. "#F4A582", "#D6604D", "#B2182B"),
  2512. cool = c("#2166AC", "#4393C3", "#92C5DE", "#D1E5F0",
  2513. "#0571B0", "#74ADD1", "#ABD9E9", "#E0F3F8"),
  2514. contrast = c("#1B9E77", "#D95F02", "#7570B3", "#E7298A",
  2515. "#66A61E", "#E6AB02", "#A6761D"),
  2516. earth = c("#A6611A", "#DFC27D", "#80CDC1", "#018571",
  2517. "#E5E5E5", "#F5F5F5", "#B2182B", "#D6604D"),
  2518. # base = c(
  2519. # "#E41A1C", "#57A156", "#06A5FF", "#7289da", "#8D5B96", "#CB7647", "#F38E38",
  2520. # "#F781BE", "#CC95C8", "#B27E85", "#9A6242", "#5FA3C9", "#3766A4", "#204E75",
  2521. # "#1B9D77", "#86CC84", "#D3EC90", "#FBF583", "#E7C715", "#F0A957", "#F57994",
  2522. # "#E7298A", "#A90D55", "#52587E", "#17CDD3", "#8ECDE0", "#BC6298", "#AE2373",
  2523. # "#5E4EA1", "#7E8D86", "#507C51", "#1F5917", "#BEC603", "#C5DD3B", "#A8DA83",
  2524. # "#8DD3C7"
  2525. # ),
  2526. base = c(
  2527. "#E41A1C", "#68618B", "#409388", "#57A156", "#8D5B96", "#CB7647", "#F38E38",
  2528. "#F781BE", "#CC95C8", "#B27E85", "#9A6242", "#5FA3C9", "#3766A4", "#204E75",
  2529. "#1B9D77", "#86CC84", "#D3EC90", "#FBF583", "#E7C715", "#F0A957", "#F57994",
  2530. "#E7298A", "#A90D55", "#52587E", "#17CDD3", "#8ECDE0", "#BC6298", "#AE2373",
  2531. "#5E4EA1", "#7E8D86", "#507C51", "#1F5917", "#BEC603", "#C5DD3B", "#A8DA83",
  2532. "#8DD3C7"
  2533. ),
  2534. custom = NULL
  2535. )
  2536. # Pick preset
  2537. preset <- match.arg(preset)
  2538. if (preset != "custom") {
  2539. base_colors <- presets[[preset]]
  2540. } else if (is.null(base_colors)) {
  2541. stop("For preset = 'custom', you must provide base_colors.")
  2542. }
  2543. # Match space argument
  2544. space <- match.arg(space)
  2545. # Short-circuit if enough colors
  2546. if (n <= length(base_colors)) {
  2547. cols <- base_colors[1:n]
  2548. if (reverse) cols <- rev(cols)
  2549. return(cols)
  2550. }
  2551. # Oversample
  2552. extra_colors <- ceiling(n * oversample_factor)
  2553. # Interpolation
  2554. if (space %in% c("rgb", "Lab")) {
  2555. extended_colors <- grDevices::colorRampPalette(base_colors, space = space)(extra_colors)
  2556. } else if (space == "HCL") {
  2557. extended_colors <- grDevices::colorRampPalette(base_colors, space = "hcl")(extra_colors)
  2558. }
  2559. # Remove grayish
  2560. if (remove_gray) {
  2561. is_grayish <- function(col) {
  2562. rgb <- grDevices::col2rgb(col)
  2563. sd(rgb) < 15
  2564. }
  2565. extended_colors <- Filter(function(c) !is_grayish(c), extended_colors)
  2566. }
  2567. # Adjust saturation / lightness
  2568. if (adjust_saturation != 1 || adjust_lightness != 1) {
  2569. hcl_vals <- colorspace::coords(
  2570. methods::as(colorspace::hex2RGB(extended_colors), "polarLUV")
  2571. )
  2572. hcl_vals[, "C"] <- pmax(0, hcl_vals[, "C"] * adjust_saturation)
  2573. hcl_vals[, "L"] <- pmax(0, pmin(100, hcl_vals[, "L"] * adjust_lightness))
  2574. extended_colors <- colorspace::hex(colorspace::polarLUV(hcl_vals))
  2575. }
  2576. # Take first n
  2577. final_colors <- extended_colors[1:n]
  2578. if (reverse) final_colors <- rev(final_colors)
  2579. return(final_colors)
  2580. }
  2581. #' Cell map visualization for Seurat objects
  2582. #'
  2583. #' @param object Seurat object
  2584. #' @param group.by Column(s) in metadata to group cells
  2585. #' @param reduction Dimensionality reduction to use (default "umap")
  2586. #' @param dims Dimensions to plot (1,2) or 3 for 3D
  2587. #' @param shuffle Logical, shuffle cells
  2588. #' @param raster Logical, rasterize plot
  2589. #' @param alpha Point transparency
  2590. #' @param repel Logical, repel labels
  2591. #' @param n.cells Logical, show number of cells in labels
  2592. #' @param label Logical, show cluster labels
  2593. #' @param label.size Cluster label size
  2594. #' @param label.face Cluster label font face
  2595. #' @param colors Named vector of colors
  2596. #' @param figplot Logical, minimal figure plot for figure panels
  2597. #' @param no.axes Logical, hide axes
  2598. #' @param plot.ttl Plot title
  2599. #' @param legend Logical, show legend
  2600. #' @param leg.ttl Legend title
  2601. #' @param item.size Legend item size
  2602. #' @param leg.pos Legend position
  2603. #' @param leg.just Legend justification
  2604. #' @param leg.dir Legend direction
  2605. #' @param leg.size Legend font size
  2606. #' @param leg.ncol Legend number of columns
  2607. #' @param item.border Logical, border around legend items
  2608. #' @param font.size Base font size
  2609. #' @param pt.size Point size
  2610. #' @param dark Logical, dark theme
  2611. #' @param total.cells Logical, include total cells in title
  2612. #' @param threeD Logical, 3D plot
  2613. #' @param theme Theme name
  2614. #' @param facet.bg Logical, add facet background
  2615. #' @param ... Additional arguments passed to DimPlot
  2616. #' @return ggplot or plotly object
  2617. #' @export
  2618. #'
  2619. cellmap <- function(
  2620. object,
  2621. group.by = NULL,
  2622. reduction = "umap",
  2623. dims = c(1,2),
  2624. shuffle = FALSE,
  2625. raster = NULL,
  2626. raster.dpi = c(512, 512),
  2627. alpha = 1,
  2628. repel = FALSE,
  2629. n.cells = TRUE,
  2630. label = FALSE,
  2631. label.size = 3.5,
  2632. label.face = "plain",
  2633. cols = NULL,
  2634. figplot = FALSE,
  2635. no.axes = FALSE,
  2636. plot.ttl = NULL,
  2637. legend = TRUE,
  2638. leg.ttl = NULL,
  2639. leg.ttl.size = font.size,
  2640. item.size = 3.5,
  2641. leg.pos = "right",
  2642. leg.just = "center",
  2643. leg.dir = "vertical",
  2644. leg.size = 10,
  2645. leg.ncol = NULL,
  2646. item.border = TRUE,
  2647. font.size = 10,
  2648. pt.size = 0.5,
  2649. dark = FALSE,
  2650. total.cells = FALSE,
  2651. threeD = FALSE,
  2652. theme.style = "classic",
  2653. facet.bg = FALSE,
  2654. ...
  2655. ) {
  2656. if (!is.null(list(...)$theme)) {
  2657. theme.style <- list(...)$theme
  2658. }
  2659. # Helper: prepare object and colors
  2660. .prepare_object <- function(obj, group, cols=NULL){
  2661. stopifnot(inherits(obj,"Seurat"))
  2662. if(is.null(group)) group <- "ident"
  2663. if(group=="ident"){
  2664. [email hidden]$ident <- Idents(obj)
  2665. } else if(!group %in% colnames([email hidden])){
  2666. stop(paste("Grouping column", group, "not found."))
  2667. }
  2668. # Drop unused levels
  2669. [email hidden][[group]] <- droplevels(factor([email hidden][[group]]))
  2670. values <- as.character([email hidden][[group]])
  2671. values[is.na(values)] <- "Unknown"
  2672. [email hidden][[group]] <- factor(values)
  2673. Idents(obj) <- [email hidden][[group]]
  2674. levels_group <- levels([email hidden][[group]])
  2675. if(is.null(cols)){
  2676. cols <- custom_palette(length(levels_group))
  2677. names(cols) <- levels_group
  2678. if("Unknown" %in% levels_group) cols["Unknown"] <- "gray70"
  2679. } else {
  2680. if(is.null(names(cols))) cols <- setNames(cols[seq_along(levels_group)], levels_group)
  2681. missing <- setdiff(levels_group, names(cols))
  2682. if(length(missing)) cols[missing] <- "gray70"
  2683. cols <- cols[levels_group]
  2684. }
  2685. list(obj=obj, cols=cols, levels_group=levels_group)
  2686. }
  2687. # 3D plotting helper
  2688. .plot_3D <- function(obj, dims, cols, alpha, pt.size, label, label.size, label.face, n.cells){
  2689. emb <- obj@reductions[[reduction]]@cell.embeddings
  2690. df <- data.frame(x=emb[, dims[1]], y=emb[, dims[2]], z=emb[, dims[3]], cluster=Idents(obj))
  2691. hover_labels <- if(n.cells){
  2692. tbl <- table(df$cluster)
  2693. paste0(df$cluster, " (", tbl[as.character(df$cluster)], ")")
  2694. } else as.character(df$cluster)
  2695. p3d <- plotly::plot_ly(df, x=~x, y=~y, z=~z, color=~cluster, colors=cols,
  2696. type="scatter3d", mode="markers",
  2697. marker=list(size=pt.size, opacity=alpha, line=list(width=0)),
  2698. text=hover_labels, hoverinfo="text")
  2699. if(label){
  2700. centers <- df %>% dplyr::group_by(cluster) %>% dplyr::summarise(x=median(x), y=median(y), z=median(z))
  2701. p3d <- p3d %>% plotly::add_text(data=centers, x=~x, y=~y, z=~z, text=~cluster, textposition="top center")
  2702. }
  2703. p3d
  2704. }
  2705. if(length(group.by) > 1){
  2706. plots <- lapply(group.by, function(g){
  2707. cellmap(
  2708. object = object,
  2709. group.by = g,
  2710. shuffle = shuffle,
  2711. raster = raster,
  2712. alpha = alpha,
  2713. repel = repel,
  2714. reduction = reduction,
  2715. dims = dims,
  2716. n.cells = n.cells,
  2717. label = label,
  2718. label.size = label.size,
  2719. label.face = label.face,
  2720. cols = cols,
  2721. figplot = figplot,
  2722. plot.ttl = g,
  2723. legend = legend,
  2724. leg.ttl = g,
  2725. item.size = item.size,
  2726. leg.pos = leg.pos,
  2727. leg.just = leg.just,
  2728. leg.dir = leg.dir,
  2729. leg.ncol = leg.ncol,
  2730. font.size = font.size,
  2731. item.border = item.border,
  2732. pt.size = pt.size,
  2733. dark = dark,
  2734. total.cells = total.cells,
  2735. threeD = threeD,
  2736. theme.style = theme.style,
  2737. facet.bg = facet.bg,
  2738. ...
  2739. )
  2740. })
  2741. return(patchwork::wrap_plots(plots))
  2742. }
  2743. # Prepare object & colors
  2744. prep <- .prepare_object(object, group.by, cols)
  2745. object <- prep$obj
  2746. cols <- prep$cols
  2747. levels_group <- prep$levels_group
  2748. if(is.null(leg.ncol)) leg.ncol <- if(length(levels_group) > 18) 2 else 1
  2749. # Validate reduction
  2750. if(!(reduction %in% names(object@reductions))){
  2751. stop(paste0("Reduction '", reduction, "' not found. Available: ", paste(names(object@reductions), collapse=", ")))
  2752. }
  2753. emb <- object@reductions[[reduction]]@cell.embeddings
  2754. if(max(dims) > ncol(emb)) stop("Selected dims exceed available dimensions in reduction.")
  2755. # 3D plotting
  2756. if(threeD || length(dims) == 3) return(.plot_3D(object, dims, cols, alpha, pt.size, label, label.size, label.face, n.cells))
  2757. # 2D plotting
  2758. plt <- Seurat::DimPlot(object, group.by=group.by, shuffle=shuffle, raster=raster, pt.size=pt.size,
  2759. repel=repel, alpha=alpha, reduction=reduction, dims=dims, raster.dpi=raster.dpi,...)
  2760. # reset Seurat's forced theme_classic
  2761. plt <- plt + ggplot2::theme_void()
  2762. present_levels <- levels(droplevels([email hidden]))
  2763. if (n.cells) {
  2764. cell.nb <- table([email hidden])[present_levels]
  2765. clust.lab <- paste0(present_levels, " (", cell.nb, ")")
  2766. } else {
  2767. clust.lab <- present_levels
  2768. }
  2769. cols_use <- cols[present_levels]
  2770. leg.ttl <- if(is.null(leg.ttl)) group.by else leg.ttl
  2771. plt <- plt + ggplot2::scale_color_manual(
  2772. breaks = present_levels,
  2773. labels = clust.lab,
  2774. values = cols_use
  2775. )
  2776. if (legend) {
  2777. plt <- plt & ggplot2::guides(
  2778. color = ggplot2::guide_legend(
  2779. override.aes = if(item.border)
  2780. list(size = item.size, shape = 21, color = "black",
  2781. stroke = 0.2, fill = unname(cols))
  2782. else
  2783. list(size = item.size),
  2784. ncol = leg.ncol,
  2785. title = leg.ttl,
  2786. keyheight = grid::unit(0.25,"cm"),
  2787. keywidth = grid::unit(0.25,"cm")
  2788. )
  2789. )
  2790. } else {
  2791. plt <- plt & ggplot2::guides(color = "none")
  2792. }
  2793. # Plot title with total cells
  2794. if(total.cells){
  2795. plot.ttl <- paste0(plot.ttl, " (n=", format(ncol(object), big.mark=","), ")")
  2796. }
  2797. #if(!is.null(plot.ttl)) plt <- plt + labs(title = plot.ttl)
  2798. if(!is.null(plot.ttl)) plt <- plt + labs(title = plot.ttl) else plt <- plt + labs(title = NULL)
  2799. # Set default legend title size
  2800. if (is.null(leg.ttl.size)) leg.ttl.size <- font.size
  2801. # Apply plot_theme using do.call
  2802. theme_args <- list(
  2803. theme.style = theme.style,
  2804. font.size = font.size,
  2805. leg.size = leg.size,
  2806. leg.pos = leg.pos,
  2807. leg.dir = leg.dir,
  2808. leg.ttl = leg.ttl,
  2809. leg.ttl.size = leg.ttl.size,
  2810. facet.bg = facet.bg,
  2811. mode = if(dark) "dark" else "light"
  2812. )
  2813. if(figplot){
  2814. # Warn if the user specified a non-classic theme
  2815. if(!missing(theme.style) && theme.style != "classic"){
  2816. warning(sprintf(
  2817. "figplot = TRUE ignores custom themes (theme.style = '%s'). Use figplot = FALSE for full theming.",
  2818. theme.style
  2819. ))
  2820. }
  2821. # Apply plot_theme for figplot figure
  2822. plt <- plt &
  2823. do.call(plot_theme, c(
  2824. theme_args,
  2825. list(
  2826. x.ttl = FALSE,
  2827. ticks = FALSE,
  2828. line = FALSE,
  2829. border = FALSE,
  2830. grid.major = FALSE,
  2831. grid.minor = FALSE,
  2832. panel.fill = "white"
  2833. ),
  2834. list(...)
  2835. ))
  2836. text_col <- "black"
  2837. } else {
  2838. # Apply plot_theme normally
  2839. plt <- plt & do.call(plot_theme, c(theme_args, list(...)))
  2840. }
  2841. # Add cluster labels if requested
  2842. if(label){
  2843. umap_data <- dplyr::tibble(x=emb[, dims[1]], y=emb[, dims[2]], cluster=as.character([email hidden])) %>%
  2844. dplyr::group_by(cluster) %>% dplyr::summarise(x=median(x), y=median(y), .groups="drop")
  2845. plt <- plt + ggrepel::geom_text_repel(
  2846. data=umap_data, aes(x, y, label=cluster),
  2847. color = if(dark) "white" else "black",
  2848. fontface = label.face,
  2849. bg.color = if(dark) "#3A3A3A" else "grey95",
  2850. bg.r = 0.1, size = label.size, seed = 42
  2851. )
  2852. }
  2853. # figplot arrow axes (minimal figure)
  2854. if(figplot){
  2855. x.lab.reduc <- plt$labels$x %||% paste0(toupper(reduction), dims[1])
  2856. y.lab.reduc <- plt$labels$y %||% paste0(toupper(reduction), dims[2])
  2857. plt <- plt & Seurat::NoAxes()
  2858. L <- 0.12
  2859. axis.df <- data.frame(x0=c(0,0), y0=c(0,0), x1=c(L,0), y1=c(0,L))
  2860. axis.plot <- ggplot2::ggplot(axis.df) +
  2861. ggplot2::geom_segment(ggplot2::aes(x=x0, y=y0, xend=x1, yend=y1), linewidth=0.4, lineend="round") +
  2862. ggplot2::xlab(x.lab.reduc) + ggplot2::ylab(y.lab.reduc) +
  2863. ggplot2::coord_fixed() + ggplot2::theme_classic(base_size=font.size) +
  2864. ggplot2::theme(plot.background=ggplot2::element_rect(fill="transparent", colour=NA),
  2865. panel.background=ggplot2::element_rect(fill="transparent", colour=NA),
  2866. axis.text=ggplot2::element_blank(),
  2867. axis.ticks=ggplot2::element_blank(),
  2868. axis.line=ggplot2::element_blank(),
  2869. panel.border=ggplot2::element_blank(),
  2870. axis.title=ggplot2::element_text(size=font.size, face="plain"),
  2871. plot.margin=ggplot2::margin(0,0,0,0))
  2872. figure.layout <- c(patchwork::area(t=1,l=1,b=11,r=11), patchwork::area(t=10,l=1,b=11,r=2))
  2873. return(plt + axis.plot + patchwork::plot_layout(design=figure.layout))
  2874. }
  2875. if(!legend) plt <- plt & Seurat::NoLegend()
  2876. if(no.axes) plt <- plt & Seurat::NoAxes()
  2877. plt
  2878. }
  2879. #' Cell Dot Plot (Enhanced Seurat DotPlot)
  2880. #'
  2881. #' A cleaner, more customizable wrapper around **Seurat::DotPlot**, providing
  2882. #' improved color handling, optional dot outlines, flexible axis formatting,
  2883. #' legend placement, and theme control. Useful for visualizing gene expression
  2884. #' patterns across clusters or metadata-defined groups.
  2885. #'
  2886. #' @param object A Seurat object.
  2887. #' @param features Character vector of features (genes or metadata fields) to plot.
  2888. #' @param group.by Column in `[email hidden]` used to group cells.
  2889. #' Default: `"seurat_clusters"`.
  2890. #'
  2891. #' @param th.cols Color palette name from **RColorBrewer** used for the
  2892. #' expression gradient. Default: `"Reds"`.
  2893. #' @param rev.th.cols Logical; reverse the gradient palette. Default: FALSE.
  2894. #'
  2895. #' @param dot.scale Numeric scale factor controlling the dot size range.
  2896. #' Passed to `Seurat::DotPlot`. Default: 4.5.
  2897. #' @param dot.outline Logical; draw outlines around dots. Default: FALSE.
  2898. #'
  2899. #' @param x.angle Angle for x-axis labels (degrees). Default: 90.
  2900. #' @param vjust.x,hjust.x Vertical and horizontal justification for x labels.
  2901. #'
  2902. #' @param flip Logical; swap x and y axes using `coord_flip()`. Default: FALSE.
  2903. #'
  2904. #' @param font.size Base font size passed to internal theme helper. Default: 8.
  2905. #'
  2906. #' @param plot.title Optional plot title.
  2907. #'
  2908. #' @param leg.size Legend text size. Default: 8.
  2909. #' @param leg.pos Position of the legend (e.g., `"right"`, `"bottom"`).
  2910. #' @param leg.just Legend justification.
  2911. #' @param leg.hjust Logical; if TRUE, use a horizontal legend layout when possible.
  2912. #'
  2913. #' @param x.axis.pos Position of the x-axis (`"top"` or `"bottom"`).
  2914. #' @param theme ggplot2 theme name used by the internal theme helper.
  2915. #'
  2916. #' @param x.face,y.face Logical; italic styling for x and/or y-axis labels.
  2917. #' @param x.ttl,y.ttl Logical; italic styling for x and/or y-axis titles.
  2918. #'
  2919. #' @param ... Additional parameters passed to `Seurat::DotPlot()`.
  2920. #'
  2921. #' @details
  2922. #' This function enhances the standard Seurat dot plot by providing:
  2923. #' * Customizable Brewer color gradients
  2924. #' * Optional dot outlines
  2925. #' * Flexible axis label styling
  2926. #' * Improved legend customization and ordering
  2927. #' * Optional axis flipping
  2928. #'
  2929. #' It retains all functionality of `Seurat::DotPlot` while adding cleaner,
  2930. #' publication-ready defaults.
  2931. #'
  2932. #' @return A ggplot object.
  2933. #'
  2934. #' @examples
  2935. #' \dontrun{
  2936. #' celldot(pbmc, features = c("MS4A1","CD3D"))
  2937. #'
  2938. #' celldot(pbmc, features = c("MS4A1","CD14"), th.cols = "Blues",
  2939. #' dot.outline = TRUE, flip = TRUE)
  2940. #' }
  2941. #'
  2942. #' @export
  2943. #'
  2944. celldot <- function(object, features, group.by="seurat_clusters", th.cols="Reds",
  2945. rev.th.cols=FALSE, dot.scale=4.5, x.angle=90, vjust.x=NULL,
  2946. hjust.x=NULL, flip=FALSE, font.size=8, plot.title=NULL,
  2947. leg.size=10, leg.pos="right", leg.just="bottom", leg.hjust=FALSE,
  2948. x.axis.pos="bottom", theme="classic", x.face=FALSE, y.face=FALSE,
  2949. x.ttl=FALSE, y.ttl=FALSE, dot.outline=FALSE, ...) {
  2950. stopifnot(inherits(object,"Seurat"))
  2951. object <- Seurat::SetIdent(object, value=group.by)
  2952. features <- unique(features)
  2953. pal <- RColorBrewer::brewer.pal(9, th.cols)
  2954. if (rev.th.cols) pal <- rev(pal)
  2955. outline_col <- if (dot.outline) "gray60" else NA
  2956. outline_stroke <- if (dot.outline) 0.5 else 0
  2957. plt <- suppressWarnings({
  2958. suppressMessages({
  2959. Seurat::DotPlot(object, features=features, dot.scale=dot.scale, ...)
  2960. })
  2961. }) +
  2962. scale_color_gradientn(colors=pal, oob=scales::squish) +
  2963. geom_point(aes(size=pct.exp), shape=21, colour=outline_col, stroke=outline_stroke) +
  2964. labs(title=plot.title, color="Average\nExpression", size="Percent\nExpressed") +
  2965. plot_theme(theme=theme, font.size=font.size, x.angle=x.angle,
  2966. x.hjust=hjust.x, x.vjust=vjust.x, xy.val=TRUE, x.lab=TRUE, y.lab=TRUE, ...) +
  2967. theme(
  2968. axis.text.x = if (x.face || (flip && y.face)) element_text(face="italic") else element_text(),
  2969. axis.text.y = if (y.face || (flip && x.face)) element_text(face="italic") else element_text(),
  2970. axis.title = element_blank(),
  2971. legend.spacing.y = unit(0.05, "cm"),
  2972. legend.spacing.x = unit(0.05, "cm"),
  2973. legend.box.spacing = unit(0.05, "cm"),
  2974. legend.margin = margin(2,2,2,2)
  2975. )
  2976. if (flip) plt <- plt + coord_flip()
  2977. if (is.list(features)) plt <- plt + theme(strip.text.x=element_text(angle=45))
  2978. # Legend positioning
  2979. # Legend positioning outside plot, bottom-right
  2980. if (!is.null(leg.pos)) {
  2981. if (leg.pos == "right") {
  2982. plt <- plt + theme(
  2983. legend.position = "right",
  2984. legend.justification = c("right","bottom"),
  2985. legend.box.just = "right",
  2986. legend.box.margin = margin(0,0,0,0)
  2987. )
  2988. } else if (leg.pos == "left") {
  2989. plt <- plt + theme(
  2990. legend.position = "left",
  2991. legend.justification = c("left","bottom"),
  2992. legend.box.just = "left",
  2993. legend.box.margin = margin(0,0,0,0)
  2994. )
  2995. } else if (leg.pos == "top") {
  2996. plt <- plt + theme(
  2997. legend.position = "top",
  2998. legend.justification = c("right","top"),
  2999. legend.box.just = "right"
  3000. )
  3001. } else if (leg.pos == "bottom") {
  3002. plt <- plt + theme(
  3003. legend.position = "bottom",
  3004. legend.justification = c("right","bottom"),
  3005. legend.box.just = "right"
  3006. )
  3007. }
  3008. }
  3009. # Keep original guides for color and size
  3010. guide_color <- guide_colorbar(frame.colour="black", ticks.colour="black")
  3011. guide_size <- guide_legend(override.aes=list(shape=21, colour=outline_col, fill="black"))
  3012. guide_color$order <- 1
  3013. guide_size$order <- 2
  3014. plt <- plt + guides(color=guide_color, size=guide_size)
  3015. plt
  3016. }
  3017. #' Cell Feature Violin Plot with Statistics
  3018. #'
  3019. #' Plots expression or metadata features as violin plots for a Seurat object,
  3020. #' optionally adding median points, shared y-axis scaling, flipped axes, and
  3021. #' statistical comparisons (Wilcoxon for 2 groups, Kruskal-Wallis for >2 groups).
  3022. #'
  3023. #' @param obj A Seurat object.
  3024. #' @param features Character vector of feature names (genes or metadata columns) to plot.
  3025. #' @param ncol Number of columns in the output patchwork plot. Defaults to sqrt(#features / 1.5).
  3026. #' @param stack Logical; if TRUE, plots are stacked in a single column.
  3027. #' @param shared.y Logical; if TRUE, all violins share the same y-axis.
  3028. #' @param ttl.pos Position of subplot titles: "center", "left", or "right".
  3029. #' @param group.by Metadata column to group by. Defaults to "seurat_clusters".
  3030. #' @param split.by Optional metadata column to split violins by.
  3031. #' @param assay Assay to pull data from. Default is "RNA".
  3032. #' @param slot Slot to use for expression values. One of "data", "counts", or "scale.data".
  3033. #' @param log Logical; if TRUE, log-transform the expression values.
  3034. #' @param cols Optional named vector of colors for each group. If NULL, defaults are used.
  3035. #' @param med Logical; if TRUE, overlay median points on each violin.
  3036. #' @param med.size Size of median points if med = TRUE.
  3037. #' @param pt.size Size of jittered points. Set to 0 to hide points.
  3038. #' @param border.size Size of the violin border lines.
  3039. #' @param txtsize Base font size for titles and labels.
  3040. #' @param theme ggplot2 theme to use: "classic", "minimal", etc.
  3041. #' @param x.ang Rotation angle of x-axis labels.
  3042. #' @param leg.pos Position of legend: "none", "right", "left", etc.
  3043. #' @param title Optional overall title for the patchwork plot.
  3044. #' @param rm.subtitles Logical; if TRUE, removes individual subplot titles.
  3045. #' @param flip Logical; if TRUE, flips x and y axes.
  3046. #' @param auto.resize Logical; if TRUE, sets dynamic width/height attributes.
  3047. #' @param ylab.global Global y-axis label. Defaults to expression level.
  3048. #' @param xlab.global Global x-axis label. Defaults to blank.
  3049. #' @param pairwise Logical; if TRUE, perform pairwise comparisons between groups.
  3050. #' @param add.stats Logical; if TRUE, add p-values to plots.
  3051. #' @param show.pval Logical; if TRUE, show p-values above violins.
  3052. #' @param pval.label Character; label type for p-values, e.g., "p.signif" or "p.format".
  3053. #' @param ... Additional arguments passed to ggplot2 layers.
  3054. #'
  3055. #' @return A patchwork object containing the violin plots.
  3056. #' @examples
  3057. #' \dontrun{
  3058. #' cellvio(sub, features = c("MEG3","TP63","HES6"),
  3059. #' group.by = "ann_level_2",
  3060. #' pt.size = 0.1, ncol = 3, pairwise = TRUE,
  3061. #' txtsize = 10, show.pval = TRUE)
  3062. #' }
  3063. #' @export
  3064. #'
  3065. cellvio <- function(
  3066. obj, features,
  3067. ncol = NULL,
  3068. shared.y = FALSE,
  3069. ttl.pos = c("center", "left", "right"),
  3070. group.by = "seurat_clusters",
  3071. split.by = NULL,
  3072. stack = FALSE,
  3073. assay = "RNA",
  3074. slot = "data",
  3075. log = FALSE,
  3076. cols = NULL,
  3077. med = FALSE,
  3078. med.size = 1,
  3079. pt.size = 0,
  3080. border.size = 0.1,
  3081. style = "classic",
  3082. leg.pos = "none",
  3083. x.ang = 45,
  3084. title = NULL,
  3085. rm.subttl = FALSE,
  3086. flip = FALSE,
  3087. auto.resize = TRUE,
  3088. ylab.global = NULL,
  3089. xlab.global = NULL,
  3090. add.stats = FALSE,
  3091. show.pval = FALSE,
  3092. pairwise = FALSE,
  3093. pval.label = "p.signif",
  3094. txtsize = 10,
  3095. ...
  3096. ) {
  3097. stopifnot(inherits(obj, "Seurat"))
  3098. if (length(features) == 0) stop("features must be provided.")
  3099. ttl.pos <- match.arg(ttl.pos)
  3100. # determine ncol
  3101. if (is.null(ncol)) {
  3102. ncol <- if (stack) 1 else max(1, ceiling(sqrt(length(features) / 1.5)))
  3103. }
  3104. # Set identities if grouping
  3105. if (!is.null(group.by)) {
  3106. if (!group.by %in% colnames([email hidden]))
  3107. stop(paste(group.by, "not found in metadata."))
  3108. Idents(obj) <- group.by
  3109. }
  3110. # Determine which features exist
  3111. f.expr <- intersect(features, rownames(obj[[assay]]))
  3112. f.meta <- intersect(features, colnames([email hidden]))
  3113. features <- unique(c(f.expr, f.meta))
  3114. if (!length(features)) stop("No features found in assay or metadata.")
  3115. # Shared y-scale
  3116. ymax <- NULL
  3117. if (shared.y) {
  3118. vals <- c(
  3119. if (length(f.expr)) as.numeric(Seurat::GetAssayData(obj, assay, slot)[f.expr, ]),
  3120. if (length(f.meta)) as.numeric(as.matrix([email hidden][, f.meta, drop = FALSE]))
  3121. )
  3122. vals <- vals[is.finite(vals)]
  3123. if (length(vals)) ymax <- max(vals)
  3124. }
  3125. # Colors
  3126. if (is.null(cols)) {
  3127. g <- tryCatch(unique(obj[[group.by]][, 1]), error = \(e) NULL)
  3128. cols <- custom_palette(length(g))
  3129. #cols <- scales::hue_pal()(if (is.null(g)) 8 else length(g))
  3130. #cols <- ggpubr::get_palette("npg", length(g))
  3131. names(cols) <- g
  3132. }
  3133. # Helper to build a single violin with stats
  3134. vln <- function(f) {
  3135. if (log && slot == "counts") {
  3136. obj[[assay]]@data[f, ] <- log1p(obj[[assay]]@counts[f, ])
  3137. slot_use <- "data"
  3138. } else {
  3139. slot_use <- slot
  3140. }
  3141. df <- Seurat::FetchData(obj, vars = c(group.by, f))
  3142. names(df)[2] <- "value"
  3143. df[[group.by]] <- factor(df[[group.by]]) # ensure factor
  3144. # Base plot
  3145. # p <- ggplot(df, aes_string(group.by, "value", fill = group.by)) +
  3146. # geom_violin(scale = "width", color = "black", size = border.size) +
  3147. # scale_fill_manual(values = cols)
  3148. p <- suppressWarnings({
  3149. suppressMessages({Seurat::VlnPlot(
  3150. obj,
  3151. features = f,
  3152. group.by = group.by,
  3153. split.by = split.by,
  3154. assay = assay,
  3155. slot = slot,
  3156. pt.size = pt.size,
  3157. cols = cols,
  3158. ...
  3159. ) + scale_y_continuous(
  3160. expand = expansion(mult = c(0.05, 0.25))
  3161. )
  3162. })
  3163. })
  3164. # Points
  3165. #if (pt.size > 0) p <- p + geom_jitter(width = 0.1, size = pt.size, alpha = 0.6)
  3166. # Add statistics
  3167. if (add.stats && show.pval) {
  3168. if (nlevels(df[[group.by]]) > 1) {
  3169. df$.grp <- df[[group.by]]
  3170. if (!is.null(split.by)) {
  3171. df$.grp <- interaction(df[[group.by]], [email hidden][[split.by]], drop = TRUE)
  3172. }
  3173. y_max <- max(df$value, na.rm = TRUE)
  3174. y_step <- (ymax %||% y_max) * 0.08
  3175. if (pairwise && nlevels(df$.grp) > 1) {
  3176. cmp <- utils::combn(levels(df$.grp), 2, simplify = FALSE)
  3177. stat_df <- ggpubr::compare_means(value ~ .grp, data = df, method = "wilcox.test", comparisons = cmp)
  3178. stat_df$y.position <- y_max + seq_len(nrow(stat_df)) * y_step
  3179. p <- p + ggpubr::stat_pvalue_manual(
  3180. stat_df,
  3181. label = pval.label,
  3182. y.position = "y.position",
  3183. tip.length = 0.02,
  3184. size = txtsize * 0.25
  3185. )
  3186. } else {
  3187. # Kruskal test if not pairwise
  3188. stat_df <- ggpubr::compare_means(value ~ .grp, data = df, method = "kruskal.test")
  3189. p <- p + annotate(
  3190. "text",
  3191. x = 1,
  3192. y = y_max + y_step,
  3193. label = paste0(signif(stat_df$p, 3)),
  3194. hjust = 0,
  3195. size = txtsize * 0.25
  3196. )
  3197. }
  3198. }
  3199. }
  3200. # Titles
  3201. p <- if (!rm.subttl) p + labs(title = f) else p + labs(title = NULL)
  3202. # Make feature titles bold + italic
  3203. p <- p + theme(
  3204. plot.title = element_text(face = "bold.italic")
  3205. )
  3206. .style_layers <- function(
  3207. p,
  3208. violin_lw = 0.15,
  3209. point_size = NULL,
  3210. jitter_width = NULL
  3211. ) {
  3212. for (i in seq_along(p$layers)) {
  3213. layer <- p$layers[[i]]
  3214. # Violin outline
  3215. if (inherits(layer$geom, "GeomViolin")) {
  3216. layer$aes_params$linewidth <- violin_lw
  3217. }
  3218. # Points (Seurat uses GeomPoint + position_jitterdodge)
  3219. if (inherits(layer$geom, "GeomPoint")) {
  3220. if (!is.null(point_size)) {
  3221. layer$aes_params$size <- point_size
  3222. layer$aes_params$alpha <- 0.6
  3223. }
  3224. if (!is.null(jitter_width) &&
  3225. inherits(layer$position, "PositionJitterdodge")) {
  3226. layer$position$width <- jitter_width
  3227. }
  3228. }
  3229. p$layers[[i]] <- layer
  3230. }
  3231. p
  3232. }
  3233. p <- .style_layers(
  3234. p,
  3235. violin_lw = border.size,
  3236. point_size = if (pt.size > 0) pt.size else NULL,
  3237. jitter_width = 0.08
  3238. )
  3239. # Theme & formatting
  3240. p <- p + plot_theme(style = style, txtsize = txtsize, x.ang = x.ang,
  3241. leg.pos = leg.pos, x.ttl = FALSE, ttl.pos = ttl.pos,...) +
  3242. theme(
  3243. plot.title = element_text(face = "bold.italic")
  3244. )
  3245. # Force final legend position
  3246. if (!is.null(leg.pos)) {
  3247. p <- p + theme(legend.position = leg.pos)
  3248. }
  3249. if (med) p <- p + stat_summary(fun = median, geom = "point", shape = 3, size = med.size)
  3250. if (!is.null(ymax)) p <- p + ylim(0, ymax)
  3251. if (flip) p <- p + coord_flip()
  3252. p + ylab(NULL)
  3253. }
  3254. # number of cols
  3255. if (is.null(ncol))
  3256. ncol <- max(1, ceiling(sqrt(length(features) / 1.5)))
  3257. plist <- lapply(features, vln)
  3258. total <- length(plist)
  3259. # Only show x-axis on bottom plots
  3260. bottom <- sapply(1:ncol, \(i) max(seq(i, total, by = ncol)))
  3261. for (i in seq_along(plist)) {
  3262. if (!(i %in% bottom)) {
  3263. plist[[i]] <- plist[[i]] +
  3264. theme(axis.text.x = element_blank(),
  3265. axis.ticks.x = element_blank())
  3266. }
  3267. }
  3268. # Layout
  3269. combo <- patchwork::wrap_plots(plist, ncol = ncol) +
  3270. patchwork::plot_layout(guides = "collect")
  3271. if (!is.null(title)) {
  3272. combo <- combo +
  3273. patchwork::plot_annotation(
  3274. title = title,
  3275. theme = theme(title = element_text(face = "bold"))
  3276. )
  3277. }
  3278. # Global labels
  3279. auto_y <- switch(slot,
  3280. data = "Expression level",
  3281. counts = "Raw counts",
  3282. scale.data = "Scaled expression",
  3283. "Expression level")
  3284. ylab <- if (is.null(ylab.global)) auto_y else ylab.global
  3285. xlab <- if (is.null(xlab.global)) "" else xlab.global
  3286. plt <- cowplot::ggdraw(combo) +
  3287. cowplot::draw_label(ylab, x = -0.01, y = 0.55, angle = 90, size = txtsize) +
  3288. cowplot::draw_label(xlab, x = 0.5, y = 0.02, size = txtsize) +
  3289. theme(plot.margin = margin(15, 15, 15, 15))
  3290. # Auto resize attributes
  3291. if (auto.resize) {
  3292. ng <- length(unique(obj[[group.by]][, 1]))
  3293. attr(plt, "dynamic_width") <- 6 + ng * 0.3
  3294. attr(plt, "dynamic_height") <- 4 + length(features) * 0.25
  3295. }
  3296. plt
  3297. }
  3298. #' Plot Unique Gene Counts Per Cell (Robust Version)
  3299. #'
  3300. #' This function visualizes the number of selected genes expressed per cell.
  3301. #' It accepts either a Seurat object with a gene list or a precomputed per-cell table.
  3302. #' Missing or unexpressed genes are automatically handled with warnings.
  3303. #'
  3304. #' @param object Either a Seurat object or a data.frame with columns `Cell` and `Unique`.
  3305. #' @param gene.list Character vector of genes to evaluate (required if `object` is a Seurat object).
  3306. #' @param plot.type One of "bar", "hist", or "violin".
  3307. #' @param font.size Numeric font size.
  3308. #' @param theme Theme type passed to `plot_theme()`.
  3309. #' @param x.lab X-axis title.
  3310. #' @param y.lab Y-axis title.
  3311. #' @param ... Additional arguments passed to `plot_theme()`.
  3312. #'
  3313. #' @return A ggplot2 object.
  3314. #' @export
  3315. plot_unique_gene_counts <- function(
  3316. object,
  3317. gene.list = NULL,
  3318. plot.type = c("bar", "hist", "violin"),
  3319. font.size = 8,
  3320. theme = "classic",
  3321. x.lab = "Number of cells",
  3322. y.lab = "Number of ORs",
  3323. color = "#20679B",
  3324. ...
  3325. ) {
  3326. # --- Get per-cell table ---
  3327. if (inherits(object, "Seurat")) {
  3328. if (is.null(gene.list)) stop("If `object` is a Seurat object, you must supply `gene.list`.")
  3329. counts <- rownames(object[["RNA"]]@counts)
  3330. genes.present <- intersect(gene.list, counts)
  3331. if (length(genes.present) == 0) stop("None of the genes in `gene.list` exist in the object.")
  3332. if (length(genes.present) < length(gene.list)) {
  3333. warning(sprintf("Only %d/%d genes found in the object. Proceeding with available genes.",
  3334. length(genes.present), length(gene.list)))
  3335. }
  3336. tbl <- get_unique_gene_table(object, genes.present)
  3337. per.cell <- as.data.frame(tbl$per.cell)
  3338. } else {
  3339. per.cell <- as.data.frame(object)
  3340. if (!all(c("Cell", "Unique") %in% colnames(per.cell))) {
  3341. stop("`object` must be a Seurat object OR a data.frame with columns: Cell, Unique")
  3342. }
  3343. }
  3344. # --- Filter non-expressing cells ---
  3345. df <- per.cell[per.cell$Unique > 0, , drop = FALSE]
  3346. if (nrow(df) == 0) stop("No cells express the selected genes.")
  3347. df$Unique.factor <- factor(df$Unique)
  3348. plot.type <- match.arg(plot.type)
  3349. # --- Generate plot ---
  3350. plt <- switch(
  3351. plot.type,
  3352. "bar" = ggplot(df, aes(x = Cell, y = Unique.factor)) +
  3353. geom_bar(stat = "identity", colour = color) +
  3354. labs(x = x.lab, y = y.lab) +
  3355. plot_theme(theme = theme, legend.position = "none", font.size = font.size) +
  3356. theme(axis.text.x = element_blank(), axis.ticks.x = element_blank()),
  3357. "hist" = ggplot(df, aes(x = Unique)) +
  3358. geom_histogram(binwidth = 1, fill = color, colour = "black") +
  3359. labs(x = y.lab, y = "Number of cells") +
  3360. plot_theme(theme = theme, font.size = font.size),
  3361. "violin" = ggplot(df, aes(x = "", y = Unique)) +
  3362. geom_violin(trim = FALSE, fill = color) +
  3363. geom_jitter(width = 0.1, alpha = 0.4) +
  3364. labs(x = "", y = y.lab) +
  3365. plot_theme(theme = theme, font.size = font.size, ...)
  3366. )
  3367. return(plt)
  3368. }
  3369. cellpct <- function(
  3370. object,
  3371. cell.col = "ann2",
  3372. group.col = "sex",
  3373. donor.col = "sample",
  3374. xttl = NULL,
  3375. yttl = "Cell type fraction per donor (log-shifted)",
  3376. pseudo = 1e-3,
  3377. jitter.size = 1.5,
  3378. alpha = 0.85,
  3379. plot_theme_fn = plot_theme,
  3380. group.colors = NULL,
  3381. donor.colors = NULL,
  3382. font.size = 10,
  3383. leg.pos = "right",
  3384. x.angle = 45,
  3385. theme.style = "classic",
  3386. leg.ncol = 1,
  3387. show.pval = TRUE,
  3388. ...
  3389. ) {
  3390. library(dplyr)
  3391. library(ggplot2)
  3392. library(rlang)
  3393. # 1. Compute donor-level fractions
  3394. frac_df <- [email hidden] %>%
  3395. group_by(!!sym(donor.col), !!sym(group.col), !!sym(cell.col)) %>%
  3396. summarise(n_cells = n(), .groups = "drop") %>%
  3397. group_by(!!sym(donor.col)) %>%
  3398. mutate(
  3399. total_cells = sum(n_cells),
  3400. fraction = n_cells / total_cells,
  3401. frac_log = log10(fraction + pseudo)
  3402. ) %>%
  3403. ungroup()
  3404. # 2. Shift log values
  3405. min_val <- min(frac_df$frac_log, na.rm = TRUE)
  3406. frac_df <- frac_df %>%
  3407. mutate(frac_shift = frac_log - min_val)
  3408. # 3. Statistics per cell type
  3409. stat_df <- frac_df %>%
  3410. distinct(!!sym(donor.col), !!sym(group.col), !!sym(cell.col), fraction, frac_shift) %>%
  3411. group_by(!!sym(cell.col)) %>%
  3412. summarise(
  3413. n_group1 = sum(!!sym(group.col) == levels(factor(!!sym(group.col)))[1]),
  3414. n_group2 = sum(!!sym(group.col) == levels(factor(!!sym(group.col)))[2]),
  3415. p_value = if (n_group1 >= 2 && n_group2 >= 2)
  3416. wilcox.test(fraction ~ !!sym(group.col), exact = FALSE)$p.value
  3417. else NA_real_,
  3418. max_val = max(frac_shift),
  3419. .groups = "drop"
  3420. ) %>%
  3421. mutate(
  3422. sig = case_when(
  3423. is.na(p_value) ~ "ns",
  3424. p_value < 0.001 ~ "***",
  3425. p_value < 0.01 ~ "**",
  3426. p_value < 0.05 ~ "*",
  3427. TRUE ~ "ns"
  3428. ),
  3429. y_pos = max_val * 1.08
  3430. )
  3431. # 3b. Merge stats into source data
  3432. source_df <- frac_df %>%
  3433. left_join(stat_df %>% dplyr::select(!!sym(cell.col), p_value, sig),by = cell.col)
  3434. # 4. Donor colors
  3435. if (!is.null(donor.colors)) {
  3436. frac_df[[donor.col]] <- factor(frac_df[[donor.col]], levels = names(donor.colors))
  3437. } else {
  3438. donors <- unique(frac_df[[donor.col]])
  3439. donor.colors <- setNames(scales::hue_pal()(length(donors)), donors)
  3440. }
  3441. # 5. Base plot
  3442. p <- ggplot(frac_df, aes(x = !!sym(cell.col), y = frac_shift, fill = !!sym(group.col))) +
  3443. stat_boxplot(geom = "errorbar", width = 0.8, position = position_dodge(0.8)) +
  3444. geom_boxplot(outlier.shape = NA, position = position_dodge(0.8)) +
  3445. geom_jitter( aes(color = !!sym(donor.col)),size = jitter.size,alpha = alpha,
  3446. position = position_jitterdodge(jitter.width = 0.15, dodge.width = 0.8)) +
  3447. labs(x = xttl, y = yttl, fill = group.col) +
  3448. plot_theme_fn(font.size = font.size,x.angle = x.angle,theme.style = theme.style,leg.pos = leg.pos,...)
  3449. # 6. Add significance labels
  3450. if (show.pval) {
  3451. p <- p + geom_text(data = stat_df,aes(x = !!sym(cell.col), y = y_pos, label = sig),
  3452. inherit.aes = FALSE,size = 4)
  3453. }
  3454. # 7. Custom colors
  3455. if (!is.null(group.colors)) p <- p + scale_fill_manual(values = group.colors)
  3456. if (!is.null(donor.colors)) p <- p + scale_color_manual(values = donor.colors)
  3457. # 8. Guides (number of columns)
  3458. if (!is.null(leg.ncol)) {
  3459. p <- p + guides(
  3460. color = guide_legend(ncol = leg.ncol, override.aes = list(size = 3)),
  3461. fill = guide_legend(ncol = leg.ncol)
  3462. )
  3463. }
  3464. # 9. Force legend position after all guides/scales
  3465. if (!is.null(leg.pos)) {p <- p + theme(legend.position = leg.pos)}
  3466. return(list(plot = p, source_data = source_df))
  3467. }
  3468. #' Plot Counts or Proportions by Group
  3469. #'
  3470. #' This function generates a ggplot2 bar, point, or box plot showing counts or
  3471. #' proportions of a variable (y) across groups (x). It supports Seurat objects
  3472. #' (uses `meta.data`), custom color palettes, stacking, coordinate flipping, and facetting.
  3473. #'
  3474. #' @param data Data frame or Seurat object (if Seurat, uses meta.data)
  3475. #' @param x Grouping variable (bare name)
  3476. #' @param y Identity variable (bare name)
  3477. #' @param plot.type Plot type: "bar", "point", or "box" (default "count")
  3478. #' @param prop Logical; if TRUE, plot proportions instead of counts
  3479. #' @param prop.multi Logical; compute proportions per group for multiple facets
  3480. #' @param stack Logical; stack bars (for bar plot) instead of dodging
  3481. #' @param coord.flip Logical; flip x and y axes
  3482. #' @param colors Vector of colors; if NULL, defaults to hue palette or RColorBrewer
  3483. #' @param use.brewer Logical; use RColorBrewer palette
  3484. #' @param brew.pal Brewer palette name (default "Set1")
  3485. #' @param raster Logical; not implemented (reserved)
  3486. #' @param theme ggplot2 theme type (default "classic")
  3487. #' @param font.size Base font size for text
  3488. #' @param x.angle Rotation angle for x-axis labels
  3489. #' @param ncol Number of columns for facet_wrap (if prop.multi)
  3490. #' @param legend Logical; whether to show legend
  3491. #' @param legend.title Legend title; if NULL, uses y variable
  3492. #' @param legend.text.size Legend text size
  3493. #' @param legend.position Legend position: "right", "bottom", etc.
  3494. #' @param legend.ncol Number of columns for legend
  3495. #' @param x.title Custom x-axis title
  3496. #' @param y.title Custom y-axis title
  3497. #' @param x.lab Logical; show x-axis labels
  3498. #' @param xy.lab Logical; show both x and y axis labels
  3499. #' @param show.contour Logical; add black border around bars (bar plot)
  3500. #'
  3501. #' @return ggplot object
  3502. #' @examples
  3503. #' \dontrun{
  3504. #' cellprop(df, x = dominance.bin, y = cluster, plot.type = "bar", prop = TRUE)
  3505. #' cellprop(sub, x = cell_group, y = dominance_bin, prop.multi = TRUE)
  3506. #' }
  3507. #' @export
  3508. cellprop <- function(
  3509. data,
  3510. x, y,
  3511. plot.type = "bar",
  3512. prop = FALSE,
  3513. prop.multi = FALSE,
  3514. percent.stack = FALSE,
  3515. stack = FALSE,
  3516. coord.flip = FALSE,
  3517. x.reverse = FALSE,
  3518. y.reverse = FALSE,
  3519. colors = NULL,
  3520. use.brewer = FALSE,
  3521. brew.pal = "Set1",
  3522. raster = FALSE,
  3523. theme = "classic",
  3524. font.size = 8,
  3525. x.angle = 90,
  3526. ncol = NULL,
  3527. legend = TRUE,
  3528. legend.title = NULL,
  3529. legend.text.size = 8,
  3530. legend.position = "right",
  3531. legend.ncol = 1,
  3532. x.title = NULL,
  3533. y.title = NULL,
  3534. x.lab = TRUE,
  3535. xy.lab = TRUE,
  3536. show.contour = TRUE,
  3537. add.pval = FALSE,
  3538. pval.test = "chisq",
  3539. pval.size = 3,
  3540. ...
  3541. ) {
  3542. require(ggplot2)
  3543. require(dplyr)
  3544. require(RColorBrewer)
  3545. # Handle Seurat
  3546. if (inherits(data, "Seurat")) data <- [email hidden]
  3547. x_var <- rlang::enquo(x)
  3548. y_var <- rlang::enquo(y)
  3549. # Count table
  3550. stat <- data %>%
  3551. dplyr::select(!!x_var, !!y_var) %>%
  3552. dplyr::rename(.group = !!x_var, .ident = !!y_var) %>%
  3553. dplyr::group_by(.group, .ident) %>%
  3554. dplyr::summarise(.n = n(), .groups = "drop")
  3555. # proportions (per x-group)
  3556. if (prop || prop.multi || percent.stack) {
  3557. stat <- stat %>%
  3558. dplyr::group_by(.group) %>%
  3559. dplyr::mutate(.value = .n / sum(.n) * 100) %>%
  3560. dplyr::ungroup()
  3561. } else {
  3562. stat$.value <- stat$.n
  3563. }
  3564. # reverse x-axis
  3565. if (x.reverse) {
  3566. stat$.group <- factor(stat$.group, levels = rev(sort(unique(stat$.group))))
  3567. } else {
  3568. stat$.group <- factor(stat$.group, levels = sort(unique(stat$.group)))
  3569. }
  3570. stat$.ident <- factor(stat$.ident)
  3571. # colors
  3572. n.colors <- length(unique(stat$.ident))
  3573. if (is.null(colors)) {
  3574. if (use.brewer) {
  3575. colors <- colorRampPalette(brewer.pal(min(9, n.colors), brew.pal))(n.colors)
  3576. } else {
  3577. colors <- scales::hue_pal()(n.colors)
  3578. }
  3579. }
  3580. # Build main plot
  3581. p <- ggplot(stat, aes(x = .group, y = .value, fill = .ident))
  3582. # stacked or dodged bars
  3583. if (plot.type == "bar") {
  3584. p <- p + geom_bar(
  3585. stat = "identity",
  3586. position = if (stack || percent.stack) "stack" else "dodge",
  3587. color = if (show.contour) "black" else NA,
  3588. linewidth = 0.2
  3589. )
  3590. }
  3591. # point/box optional
  3592. if (plot.type == "point") p <- p + geom_point(size = 2)
  3593. if (plot.type == "box") p <- p + geom_boxplot()
  3594. # percent stacked bar → fix y-scale to 100%
  3595. if (percent.stack) {
  3596. p <- p + scale_y_continuous(limits = c(0,100), expand = expansion(mult = c(0, 0.05)))
  3597. y.title <- "Percent (%)"
  3598. }
  3599. # prop.multi facet mode
  3600. if (prop.multi) {
  3601. p <- p +
  3602. facet_wrap(~.ident, scales = "free_y", ncol = ncol) +
  3603. scale_y_continuous(labels = function(x) paste0(x, "%"))
  3604. }
  3605. # axis reversing
  3606. if (coord.flip) p <- p + coord_flip()
  3607. if (y.reverse && !percent.stack && !prop.multi) p <- p + scale_y_reverse()
  3608. # labels + theme
  3609. p <- p +
  3610. scale_fill_manual(values = colors) +
  3611. plot_theme(theme = theme, font.size = font.size, x.angle = x.angle, x.lab = x.lab, xy.lab = xy.lab, ...) +
  3612. labs(
  3613. x = if (!is.null(x.title)) x.title else rlang::as_name(x_var),
  3614. y = if (!is.null(y.title)) y.title else if (prop || prop.multi || percent.stack) "Percent (%)" else "Count",
  3615. fill = if (!is.null(legend.title)) legend.title else rlang::as_name(y_var)
  3616. ) +
  3617. theme(
  3618. legend.title = element_text(size = legend.text.size),
  3619. legend.text = element_text(size = legend.text.size),
  3620. legend.position = if (legend) legend.position else "none"
  3621. ) +
  3622. guides(fill = guide_legend(ncol = legend.ncol))
  3623. # ADD P-VALUES
  3624. if (add.pval) {
  3625. pval.df <- stat %>%
  3626. tidyr::pivot_wider(names_from = .ident, values_from = .n, values_fill = 0) %>%
  3627. dplyr::rowwise() %>%
  3628. mutate(
  3629. pval =
  3630. if (pval.test == "chisq")
  3631. chisq.test(c_across(!.group))$p.value
  3632. else if (pval.test == "fisher")
  3633. fisher.test(matrix(c_across(!.group), nrow = 1))$p.value
  3634. else NA_real_
  3635. ) %>%
  3636. ungroup() %>%
  3637. mutate(
  3638. label = paste0("p=", signif(pval, 2)),
  3639. y.position = max(stat$.value) * 1.05
  3640. )
  3641. p <- p +
  3642. geom_text(
  3643. data = pval.df,
  3644. aes(x = .group, y = y.position, label = label),
  3645. inherit.aes = FALSE,
  3646. size = pval.size
  3647. )
  3648. }
  3649. return(p)
  3650. }
  3651. ##' Pseudobulk cell-type proportion plotting with statistics
  3652. #'
  3653. #' Generate pseudobulk cell-type proportion plots from single-cell data,
  3654. #' supporting Seurat objects or precomputed data frames. The function
  3655. #' computes donor-level proportions, summarizes data as mean ± SEM,
  3656. #' performs statistical testing, and returns both the plot and
  3657. #' Nature-style source data.
  3658. #'
  3659. #' @param input A \code{Seurat} object or a data frame containing
  3660. #' donor-, stage-, and cell-level information.
  3661. #' @param donor.col Column name identifying biological replicates
  3662. #' (e.g. patient, sample, donor).
  3663. #' @param stage.col Column name identifying experimental groups or stages.
  3664. #' @param cell.col Column name identifying cell types or clusters.
  3665. #' @param prop.col Optional column name containing precomputed proportions.
  3666. #' If NULL, proportions are calculated automatically.
  3667. #' @param plot.type Plot type: \code{"bar"} (mean ± SEM with points)
  3668. #' or \code{"box"} (distribution with jittered points).
  3669. #' @param min.lmm Minimum number of donors per group required to apply
  3670. #' linear mixed models. Below this threshold, Kruskal–Wallis is used.
  3671. #' @param facet.by Variable used for faceting (typically \code{"cell"}).
  3672. #' @param cell.order Optional character vector specifying cell-type order.
  3673. #' @param palette Brewer palette name used when \code{stage.cols} is NULL.
  3674. #' @param donor.cols Optional named vector of colors for donors.
  3675. #' @param stage.cols Optional named vector of colors for stages/groups.
  3676. #' @param show.p Logical; whether to display p-values on the plot.
  3677. #' @param title Plot title.
  3678. #' @param x.lab X-axis label.
  3679. #' @param y.lab Y-axis label.
  3680. #' @param leg.pos Legend position passed to \code{plot_theme()}.
  3681. #' @param pt.size Point size for jittered donor-level points.
  3682. #' @param ncol Number of columns for facet wrapping.
  3683. #' @param ... Additional arguments passed directly to \code{plot_theme()}.
  3684. #'
  3685. #' @details
  3686. #' For Seurat objects, cell-type proportions are computed per donor
  3687. #' (pseudobulk). Summary statistics are calculated as mean ± SEM
  3688. #' across donors. Statistical testing is performed independently
  3689. #' for each cell type:
  3690. #'
  3691. #' \itemize{
  3692. #' \item Linear mixed model (LMM): \code{prop ~ stage + (1 | donor)}
  3693. #' \item Kruskal–Wallis test when donor numbers are insufficient
  3694. #' }
  3695. #'
  3696. #' The returned \code{source_data} table is suitable for direct inclusion
  3697. #' as Nature-style Source Data and includes raw proportions, donor counts,
  3698. #' summary statistics, and p-values.
  3699. #'
  3700. #' @return A list with two elements:
  3701. #' \describe{
  3702. #' \item{plot}{A \code{ggplot2} object.}
  3703. #' \item{source_data}{A data frame containing raw and summarized data,
  3704. #' statistical results, and donor counts.}
  3705. #' }
  3706. #'
  3707. #' @examples
  3708. #' res <- cellpbulk(
  3709. #' input = seurat_obj,
  3710. #' donor.col = "sample",
  3711. #' stage.col = "condition",
  3712. #' cell.col = "celltype",
  3713. #' plot.type = "bar",
  3714. #' show.p = TRUE
  3715. #' )
  3716. #'
  3717. #' res$plot
  3718. #' head(res$source_data)
  3719. #'
  3720. #' @export
  3721. cellpbulk <- function(
  3722. input,
  3723. donor.col = "sample",
  3724. stage.col = "stage",
  3725. cell.col = "ann2",
  3726. prop.col = NULL,
  3727. plot.type = c("bar", "box"),
  3728. min.lmm = 3,
  3729. facet.by = "cell",
  3730. cell.order = NULL,
  3731. palette = "Set3",
  3732. donor.cols = NULL,
  3733. stage.cols = NULL,
  3734. show.p = TRUE,
  3735. title = NULL,
  3736. x.lab = NULL,
  3737. y.lab = "Proportion (%)",
  3738. leg.pos = "right",
  3739. pt.size = 2,
  3740. label.size = 3,
  3741. ncol = NULL,
  3742. theme.style = "classic",
  3743. facet.title.size = 10,
  3744. ...
  3745. ) {
  3746. library(dplyr)
  3747. library(ggplot2)
  3748. library(lme4)
  3749. plot.type <- match.arg(plot.type)
  3750. # 1. Build pseudobulk dataframe
  3751. if (inherits(input, "Seurat")) {
  3752. meta <- [email hidden] %>% as.data.frame()
  3753. df <- meta %>%
  3754. mutate(
  3755. donor = .data[[donor.col]],
  3756. stage = .data[[stage.col]],
  3757. cell = .data[[cell.col]]
  3758. ) %>%
  3759. group_by(donor, stage, cell) %>%
  3760. summarise(n_cells = n(), .groups = "drop") %>%
  3761. group_by(donor, stage) %>%
  3762. mutate(prop = n_cells / sum(n_cells)) %>%
  3763. ungroup()
  3764. } else {
  3765. df <- input %>%
  3766. rename(
  3767. donor = all_of(donor.col),
  3768. stage = all_of(stage.col),
  3769. cell = all_of(cell.col)
  3770. )
  3771. if (!is.null(prop.col)) {
  3772. df <- df %>% rename(prop = all_of(prop.col))
  3773. } else if (!"prop" %in% colnames(df)) {
  3774. stop("prop.col must be provided or 'prop' column must exist")
  3775. }
  3776. }
  3777. # 2. Factor ordering
  3778. if (!is.null(cell.order)) {
  3779. df$cell <- factor(df$cell, levels = intersect(cell.order, unique(df$cell)))
  3780. } else {
  3781. df$cell <- factor(df$cell)
  3782. }
  3783. # 3. Donor counts + Mean ± SEM
  3784. donor_n <- df %>%
  3785. group_by(cell, stage) %>%
  3786. summarise(n_donor = n_distinct(donor), .groups = "drop")
  3787. summary_stats <- df %>%
  3788. group_by(cell, stage) %>%
  3789. summarise(
  3790. n = n_distinct(donor),
  3791. mean = mean(prop, na.rm = TRUE),
  3792. sd = sd(prop, na.rm = TRUE),
  3793. sem = sd / sqrt(n),
  3794. .groups = "drop"
  3795. )
  3796. # 4. Statistics (LMM / KW)
  3797. stats <- lapply(levels(df$cell), function(ct) {
  3798. tmp <- df %>% filter(cell == ct)
  3799. n_ds <- donor_n %>% filter(cell == ct) %>% pull(n_donor)
  3800. if (all(n_ds >= min.lmm)) {
  3801. fit <- try(lmer(prop ~ stage + (1 | donor), data = tmp), silent = TRUE)
  3802. p <- if (!inherits(fit, "try-error")) anova(fit)$`Pr(>F)`[1] else NA
  3803. data.frame(cell = ct, method = "LMM", p.value = p)
  3804. } else if (length(unique(tmp$stage)) > 1) {
  3805. kw <- kruskal.test(prop ~ stage, data = tmp)
  3806. data.frame(cell = ct, method = "KW", p.value = kw$p.value)
  3807. } else {
  3808. data.frame(cell = ct, method = NA, p.value = NA)
  3809. }
  3810. }) %>% bind_rows()
  3811. # 5. Plot
  3812. fill.scale <- if (!is.null(stage.cols)) {
  3813. scale_fill_manual(values = stage.cols)
  3814. } else {
  3815. scale_fill_brewer(palette = palette)
  3816. }
  3817. if (plot.type == "bar") {
  3818. p <- ggplot(df, aes(stage, prop, fill = stage)) +
  3819. stat_summary(fun = mean, geom = "bar",
  3820. color = "black", linewidth = 0.25, width = 0.7) +
  3821. stat_summary(fun.data = mean_se, geom = "errorbar",
  3822. width = 0.25, linewidth = 0.3) +
  3823. geom_jitter(aes(color = donor),
  3824. color = "black",
  3825. stroke = 0.3,
  3826. shape = 21, size = pt.size,
  3827. width = 0.1, alpha = 0.8)
  3828. } else {
  3829. p <- ggplot(df, aes(stage, prop, fill = stage)) +
  3830. geom_boxplot(alpha = 0.5) +
  3831. geom_jitter(aes(color = donor),
  3832. color = "black",
  3833. shape = 21, size = pt.size,
  3834. width = 0.1, alpha = 0.8)
  3835. }
  3836. if (!is.null(donor.cols)) {
  3837. p <- p + scale_color_manual(values = donor.cols)
  3838. }
  3839. p <- p +
  3840. fill.scale +
  3841. facet_wrap(as.formula(paste("~", facet.by)),scales = "free_y",ncol = ncol) +
  3842. plot_theme(theme.style = theme.style, leg.pos = leg.pos, ...) +
  3843. labs(title = title, x = x.lab, y = y.lab) +
  3844. theme(legend.position = leg.pos, strip.text = element_text(size = facet.title.size))
  3845. # 6. P-value annotation
  3846. if (show.p) {
  3847. annot <- df %>%
  3848. group_by(cell) %>%
  3849. summarise(y = max(prop, na.rm = TRUE), .groups = "drop") %>%
  3850. left_join(stats, by = "cell") %>%
  3851. mutate(label = ifelse(is.na(p.value), "",
  3852. paste0("p=", signif(p.value, 2))))
  3853. annot$cell <- factor(annot$cell, levels = levels(df$cell))
  3854. p <- p + geom_text(
  3855. data = annot,
  3856. aes(x = 2, y = y * 1.05, label = label),
  3857. inherit.aes = FALSE,
  3858. size = label.size
  3859. )
  3860. }
  3861. # 7. Source data (Nature-ready)
  3862. source_data <- df %>%
  3863. left_join(donor_n, by = c("cell", "stage")) %>%
  3864. left_join(summary_stats, by = c("cell", "stage")) %>%
  3865. left_join(stats, by = "cell") %>%
  3866. arrange(cell, stage, donor)
  3867. return(list(
  3868. plot = p,
  3869. source_data = source_data
  3870. ))
  3871. }
  3872. #' Summarize and Plot Gene Set Expression
  3873. #'
  3874. #' @description
  3875. #' Computes the total expression of a given gene set across cells, optionally summarized
  3876. #' in pseudobulk per group. Produces a bar plot showing summed expression per group/fill variable.
  3877. #'
  3878. #' @param object A Seurat object containing gene expression data.
  3879. #' @param gene.list A character vector of gene names to include.
  3880. #' @param group.var Metadata column to group cells by (x-axis). Default: "ann2".
  3881. #' @param fill.var Metadata column to use for fill colors (stacked or dodged bars). Default: "PCW".
  3882. #' @param sample.var Metadata column for pseudobulk (optional). If provided, aggregates by sample.
  3883. #' @param pseudobulk.mode Character. If "sum", sums expression per sample; if "cpm", normalizes counts per million. Default: NULL (no pseudobulk).
  3884. #' @param group.levels Optional factor levels for group.var.
  3885. #' @param fill.levels Optional factor levels for fill.var.
  3886. #' @param fill.colors Optional named vector of colors for fill levels.
  3887. #' @param expr.threshold Minimum expression to retain a gene. Default: 0.
  3888. #' @param theme ggplot2 theme. Default: "minimal".
  3889. #' @param leg.pos Legend position. Default: "right".
  3890. #' @param leg.dir Legend direction. Default: "vertical".
  3891. #' @param leg.ttl Legend title. Default: "".
  3892. #' @param font.size Base font size. Default: 10.
  3893. #' @param x.angle X-axis text angle. Default: 45.
  3894. #' @param x.lab X-axis label. Default: NULL.
  3895. #' @param y.lab Y-axis label. Default: "Total transcripts".
  3896. #' @param plot.ttl Plot title. Default: NULL.
  3897. #' @param flip Logical. If TRUE, flips coordinates. Default: FALSE.
  3898. #' @param return.data Logical. If TRUE, returns a list with plot and summarized data. Default: FALSE.
  3899. #' @param ... Additional arguments passed to `plot_theme`.
  3900. #'
  3901. #' @return A ggplot object (or list with plot and summarized data if `return.data = TRUE`).
  3902. #' @export
  3903. #'
  3904. #' @examples
  3905. #' genesum(
  3906. #' object = seurat_obj,
  3907. #' gene.list = c("OR1", "OR2"),
  3908. #' group.var = "ann2",
  3909. #' fill.var = "stage",
  3910. #' sample.var = "sample",
  3911. #' pseudobulk.mode = "cpm",
  3912. #' plot.ttl = "OR pseudobulk across development"
  3913. #' )
  3914. #'
  3915. genesum <- function(
  3916. object,
  3917. gene.list,
  3918. group.var = "ann2",
  3919. fill.var = "stage",
  3920. sample.var = NULL,
  3921. mode = c("cell", "pseudobulk"),
  3922. pb.norm = c("none", "cpm"),
  3923. group.levels = NULL,
  3924. fill.levels = NULL,
  3925. fill.colors = NULL,
  3926. expr.threshold = 0,
  3927. theme = "classic",
  3928. leg.pos = "right",
  3929. leg.dir = "vertical",
  3930. leg.ttl = "",
  3931. font.size = 10,
  3932. x.angle = 45,
  3933. x.lab = NULL,
  3934. y.lab = "Total transcripts",
  3935. plot.ttl = NULL,
  3936. flip = FALSE,
  3937. return.data = FALSE,
  3938. ...
  3939. ) {
  3940. require(ggplot2)
  3941. require(dplyr)
  3942. require(reshape2)
  3943. mode <- match.arg(mode)
  3944. pb.norm <- match.arg(pb.norm)
  3945. ## 1. Filter genes (FAST, sparse-safe)
  3946. counts <- Seurat::GetAssayData(object, assay = "RNA", slot = "counts")
  3947. valid.genes <- intersect(gene.list, rownames(counts))
  3948. if (!length(valid.genes)) stop("No valid genes found")
  3949. counts <- counts[valid.genes, , drop = FALSE]
  3950. # Remove low-expression genes
  3951. keep.genes <- Matrix::rowSums(counts) > expr.threshold
  3952. counts <- counts[keep.genes, , drop = FALSE]
  3953. ## 2. Total expression per cell (FAST)
  3954. cell_totals <- Matrix::colSums(counts)
  3955. meta <- [email hidden]
  3956. meta$total_expr <- cell_totals[colnames(object)]
  3957. ## 3. Aggregation
  3958. if (mode == "cell") {
  3959. summary.df <- meta %>%
  3960. group_by(.data[[group.var]], .data[[fill.var]]) %>%
  3961. summarise(
  3962. total = sum(total_expr),
  3963. n = n(),
  3964. sd = sd(total_expr),
  3965. sem = sd / sqrt(n),
  3966. .groups = "drop"
  3967. )
  3968. }
  3969. if (mode == "pseudobulk") {
  3970. if (is.null(sample.var))
  3971. stop("sample.var must be provided for pseudobulk mode")
  3972. pb <- meta %>%
  3973. group_by(.data[[sample.var]],
  3974. .data[[group.var]],
  3975. .data[[fill.var]]) %>%
  3976. summarise(total_expr = sum(total_expr), .groups = "drop")
  3977. if (pb.norm == "cpm") {
  3978. libsize <- pb %>%
  3979. group_by(.data[[sample.var]]) %>%
  3980. summarise(lib = sum(total_expr), .groups = "drop")
  3981. pb <- pb %>%
  3982. left_join(libsize, by = sample.var) %>%
  3983. mutate(total_expr = total_expr / lib * 1e6)
  3984. }
  3985. summary.df <- pb %>%
  3986. group_by(.data[[group.var]], .data[[fill.var]]) %>%
  3987. summarise(
  3988. total = sum(total_expr),
  3989. n = n(),
  3990. sd = sd(total_expr),
  3991. sem = sd / sqrt(n),
  3992. .groups = "drop"
  3993. )
  3994. }
  3995. ## 4. Factors
  3996. if (!is.null(group.levels))
  3997. summary.df[[group.var]] <- factor(summary.df[[group.var]], levels = group.levels)
  3998. if (!is.null(fill.levels))
  3999. summary.df[[fill.var]] <- factor(summary.df[[fill.var]], levels = fill.levels)
  4000. ## 5. Plot
  4001. pd <- position_dodge(width = 0.7) # increase width to create space between bars within fill.var
  4002. bar_width <- 0.5 # set bar width
  4003. min_frac <- 0.05
  4004. p <- ggplot(summary.df, aes(x = .data[[group.var]], y = total, fill = .data[[fill.var]])) +
  4005. geom_bar(stat = "identity", position = pd, color = "black", linewidth = 0.25, width = bar_width) +
  4006. geom_errorbar(aes(ymin = total - pmax(sem, min_frac * total),
  4007. ymax = total + pmax(sem, min_frac * total)),
  4008. position = pd, width = 0.25, linewidth = 0.25) +
  4009. guides(fill = guide_legend(title = leg.ttl)) +
  4010. labs(x = x.lab, y = y.lab, title = plot.ttl) +
  4011. plot_theme(theme = theme, leg.pos = leg.pos, leg.dir = leg.dir,
  4012. font.size = font.size, x.angle = x.angle, leg.size = font.size, ...)
  4013. if (!is.null(fill.colors))
  4014. p <- p + scale_fill_manual(values = fill.colors)
  4015. if (flip)
  4016. p <- p + coord_flip()
  4017. if (return.data)
  4018. return(list(plot = p, data = summary.df))
  4019. return(p)
  4020. }
  4021. #'#' Highlight selected clusters on UMAP/tSNE plots
  4022. #'
  4023. #' @description
  4024. #' Plots a dimensionality reduction (UMAP/tSNE) and highlights specified clusters.
  4025. #' Can highlight clusters individually (mode = "single") or
  4026. #' simultaneously (mode = "multi").
  4027. #'
  4028. #' @param object Seurat object
  4029. #' @param cluster.names Character vector of clusters to highlight.
  4030. #' If NULL, all identities are used.
  4031. #' @param group.by Metadata column used for cluster identity.
  4032. #' @param reduction Dimensional reduction name (default: "umap")
  4033. #' @param mode "single" = one plot per cluster, "multi" = combined highlight
  4034. #' @param ncol Number of columns for "single" mode layout
  4035. #' @param custom.colors Named vector of colors corresponding to highlighted clusters.
  4036. #' @param pt.size Baseline point size for non-highlight cells
  4037. #' @param highlight.size Point size for highlighted cells
  4038. #' @param fontsize Base text size
  4039. #' @param show.cell.counts Logical, adds cell numbers to legend
  4040. #' @param background.color Color for all non-highlighted cells
  4041. #' @param label Whether to label clusters (multi mode only)
  4042. #' @param label.size Cluster label font size
  4043. #' @param plot.title Custom plot title (NULL removes the default title)
  4044. #' @param no_axes Remove axes (recommended for UMAP/tSNE)
  4045. #' @param legend.position Legend position ("none" by default)
  4046. #' @param item.size Size of legend point markers
  4047. #' @param legend.ncol Number of legend columns
  4048. #' @param legend.title Title for legend (NULL hides it)
  4049. #' @param legend.text.size Legend text size
  4050. #' @param ... Additional arguments to DimPlot
  4051. #'
  4052. #' @return A ggplot2 or patchwork object
  4053. #' @export
  4054. #'
  4055. cellmark <- function(
  4056. object,
  4057. cluster.names = NULL,
  4058. group.by = "seurat_clusters",
  4059. reduction = "umap",
  4060. mode = c("single", "multi"),
  4061. ncol = 3,
  4062. custom.colors = NULL,
  4063. pt.size = 0.1,
  4064. highlight.size = 0.5,
  4065. fontsize = 10,
  4066. show.cell.counts = FALSE,
  4067. background.color = "lightgray",
  4068. label = FALSE,
  4069. label.size = 4,
  4070. plot.title = NULL,
  4071. no.axes = TRUE,
  4072. legend.position = "none",
  4073. item.size = 3,
  4074. legend.ncol = 1,
  4075. legend.title = NULL,
  4076. legend.text.size = 10,
  4077. ...
  4078. ) {
  4079. require(Seurat)
  4080. require(ggplot2)
  4081. require(patchwork)
  4082. require(dplyr)
  4083. require(ggrepel)
  4084. mode <- match.arg(mode)
  4085. object <- SetIdent(object, value = group.by)
  4086. clusters <- levels(Idents(object))
  4087. if (is.null(cluster.names)) cluster.names <- clusters
  4088. valid.clusters <- cluster.names[cluster.names %in% clusters]
  4089. if (length(valid.clusters) == 0)
  4090. stop("No valid clusters found in metadata.")
  4091. if (is.null(custom.colors)) {
  4092. pal <- custom_palette(n = length(valid.clusters))
  4093. names(pal) <- valid.clusters
  4094. custom.colors <- pal
  4095. }
  4096. # MODE = SINGLE
  4097. if (mode == "single") {
  4098. plots <- lapply(valid.clusters, function(cl) {
  4099. cells <- WhichCells(object, idents = cl)
  4100. cnt <- length(cells)
  4101. title <- if (show.cell.counts) paste0(cl, " (n=", cnt, ")") else cl
  4102. p <- suppressWarnings({
  4103. suppressMessages({DimPlot(
  4104. object,
  4105. reduction = reduction,
  4106. group.by = group.by,
  4107. cells.highlight = cells,
  4108. cols = background.color,
  4109. cols.highlight = custom.colors[cl],
  4110. pt.size = pt.size,
  4111. sizes.highlight = highlight.size,
  4112. order = TRUE,
  4113. ...
  4114. ) })})+
  4115. ggtitle(title) +
  4116. theme(
  4117. plot.title = element_text(hjust = 0.5, size = fontsize, face = "bold"),
  4118. text = element_text(size = fontsize)
  4119. ) &
  4120. NoLegend()
  4121. if (no_axes) p <- p & NoAxes()
  4122. return(p)
  4123. })
  4124. return(wrap_plots(plots, ncol = ncol))
  4125. }
  4126. # MODE = MULTI
  4127. cells.to.highlight <- CellsByIdentities(object, idents = valid.clusters)
  4128. plt <- suppressWarnings({
  4129. suppressMessages({DimPlot(
  4130. object,
  4131. reduction = reduction,
  4132. group.by = group.by,
  4133. cells.highlight = cells.to.highlight,
  4134. cols.highlight = custom.colors[valid.clusters],
  4135. cols = background.color,
  4136. pt.size = pt.size,
  4137. sizes.highlight = highlight.size,
  4138. order = TRUE,
  4139. ...
  4140. ) }) })
  4141. # Ensure consistent color scale ONLY once
  4142. plt <- plt +
  4143. scale_color_manual(
  4144. values = custom.colors[valid.clusters],
  4145. breaks = valid.clusters,
  4146. na.value = background.color
  4147. ) +
  4148. guides(
  4149. color = guide_legend(
  4150. override.aes = list(
  4151. size = item.size,
  4152. shape = 21,
  4153. fill = unname(custom.colors[valid.clusters]),
  4154. stroke = 0.3,
  4155. color = "black"
  4156. ),
  4157. ncol = legend.ncol,
  4158. title = legend.title
  4159. )
  4160. ) #+
  4161. # theme(
  4162. # legend.position = legend.position,
  4163. # legend.text = element_text(size = legend.text.size),
  4164. # text = element_text(size = fontsize)
  4165. # )
  4166. plt <- plt +
  4167. theme(
  4168. legend.position = legend.position,
  4169. legend.text = element_text(size = legend.text.size),
  4170. legend.title = element_text(size = legend.text.size, face = "bold"),
  4171. legend.spacing.y = unit(1, "pt"),
  4172. legend.key.height = unit(4, "pt"),
  4173. legend.key.width = unit(4, "pt"),
  4174. legend.margin = margin(1, 1, 1, 1),
  4175. text = element_text(size = fontsize)
  4176. )
  4177. # Plot title rule
  4178. if (is.null(plot.title)) {
  4179. plt <- plt + theme(plot.title = element_blank())
  4180. } else {
  4181. plt <- plt + ggtitle(plot.title) +
  4182. theme(plot.title = element_text(
  4183. hjust = 0.5, size = fontsize + 2, face = "bold"
  4184. ))
  4185. }
  4186. # Labels on highlighted clusters
  4187. if (label) {
  4188. emb <- Embeddings(object[[reduction]])
  4189. df <- data.frame(emb, cluster = Idents(object)) %>%
  4190. filter(cluster %in% valid.clusters) %>%
  4191. group_by(cluster) %>%
  4192. summarise(x = median(umap_1), y = median(umap_2), .groups = "drop")
  4193. plt <- plt +
  4194. geom_text_repel(
  4195. data = df,
  4196. aes(x = x, y = y, label = cluster),
  4197. size = label.size,
  4198. fontface = "bold"
  4199. )
  4200. }
  4201. # Cell count labels in legend
  4202. if (show.cell.counts) {
  4203. cell.nb <- table(Idents(object))
  4204. lbl <- paste0(valid.clusters, " (",cell.nb[valid.clusters], ")")
  4205. names(lbl) <- valid.clusters
  4206. plt <- plt +
  4207. scale_color_manual(
  4208. values = custom.colors[valid.clusters],
  4209. breaks = valid.clusters,
  4210. labels = lbl,
  4211. na.value = background.color
  4212. )
  4213. }
  4214. if (no.axes) plt <- plt & NoAxes()
  4215. return(plt)
  4216. }
  4217. #' Cell Feature Plot for Seurat Objects
  4218. #'
  4219. #' A flexible wrapper around Seurat's `FeaturePlot` to visualize gene expression or metadata
  4220. #' features in a Seurat object. Supports custom color palettes, viridis, and hotspot/rainbow palettes.
  4221. #'
  4222. #' @param object A `Seurat` object.
  4223. #' @param features Character vector of features (genes or metadata columns) to plot.
  4224. #' @param cols Optional character vector of colors for plotting.
  4225. #' @param theme.cols Character. Predefined theme color palette (default: `"Reds"`). Options include `"Reds"`, `"Blues"`, etc., or custom list palettes `"hotspot"` and `"rainbow"`.
  4226. #' @param viridis Logical. If TRUE, use viridis palette instead of RColorBrewer or custom palettes.
  4227. #' @param viridis.opt Character. Viridis palette option (default: `"D"`).
  4228. #' @param rev.cols Logical. Reverse the color palette (default: `FALSE`).
  4229. #' @param na.col Color for NA or below-cutoff expression values (default: `"lightgray"`).
  4230. #' @param order Logical. If TRUE, plot high-expression cells on top (default: `FALSE`).
  4231. #' @param pt.size Numeric. Point size. If NULL, automatically calculated based on number of cells.
  4232. #' @param font.size Numeric. Base font size for plot titles and axis labels (default: 10).
  4233. #' @param reduction Character. Dimensional reduction to use (default: first available in Seurat object).
  4234. #' @param na.cutoff Numeric. Minimum expression value for coloring; below this will be NA if palette requires (default: 1e-9).
  4235. #' @param raster Logical. If TRUE, rasterize points for faster plotting of large datasets.
  4236. #' @param raster.dpi Numeric vector of length 2. DPI for rasterization (default: c(512,512)).
  4237. #' @param split.by Character. Metadata column to split the plot.
  4238. #' @param ncol Numeric. Number of columns when combining multiple plots.
  4239. #' @param layer Character. Seurat assay slot to fetch data from (default: `"data"`).
  4240. #' @param label Logical. Whether to label clusters (default: FALSE).
  4241. #' @param axes Logical. Whether to show axes (default: TRUE).
  4242. #' @param combine Logical. Whether to return a single combined plot (default: TRUE).
  4243. #' @param blend Logical. Whether to blend exactly two features (default: FALSE).
  4244. #' @param merge.leg Logical. Whether to merge multiple legends into one (default: FALSE).
  4245. #' @param theme.style Character. ggplot2 theme to apply (default: `"classic"`).
  4246. #' @param ... Additional arguments passed to `Seurat::FeaturePlot`.
  4247. #'
  4248. #' @return A `ggplot` object (or `patchwork` object if multiple features).
  4249. #' @export
  4250. #'
  4251. #' @examples
  4252. #' # Single feature with default Reds palette
  4253. #' cellfeat(seurat_obj, features = "POMC")
  4254. #'
  4255. #' # Multiple features with viridis palette and merged legend
  4256. #' cellfeat(seurat_obj, features = c("POMC", "NPY"), viridis = TRUE, merge.leg = TRUE)
  4257. #'
  4258. #' # Blend two features
  4259. #' cellfeat(seurat_obj, features = c("POMC", "NPY"), blend = TRUE)
  4260. #'
  4261. cellfeat <- function(
  4262. object,
  4263. features,
  4264. cols = NULL,
  4265. theme.cols = "Reds",
  4266. viridis = FALSE,
  4267. viridis.opt = "D",
  4268. rev.cols = FALSE,
  4269. na.col = "lightgray",
  4270. order = FALSE,
  4271. pt.size = NULL,
  4272. font.size = 10,
  4273. reduction = NULL,
  4274. na.cutoff = 1e-9,
  4275. raster = NULL,
  4276. raster.dpi = c(512, 512),
  4277. split.by = NULL,
  4278. ncol = NULL,
  4279. layer = "data",
  4280. label = FALSE,
  4281. axes = TRUE,
  4282. combine = TRUE,
  4283. blend = FALSE,
  4284. merge.leg = FALSE,
  4285. theme.style = "classic",
  4286. ...
  4287. ) {
  4288. suppressWarnings(suppressMessages({
  4289. # Checks
  4290. if (!inherits(object, "Seurat"))
  4291. stop("object must be a Seurat object")
  4292. features <- intersect(
  4293. unique(as.character(features)),
  4294. c(rownames(object), colnames([email hidden]))
  4295. )
  4296. if (length(features) == 0)
  4297. stop("No valid features found")
  4298. reduction <- reduction %||% SeuratObject::DefaultDimReduc(object)
  4299. # Colors
  4300. # if (is.null(cols)) {
  4301. # cols <- RColorBrewer::brewer.pal(9, theme.cols)
  4302. # }
  4303. # if (rev.cols) cols <- rev(cols)
  4304. # Colors
  4305. if (!is.null(cols)) {
  4306. if (length(cols) < 2)
  4307. stop("colors_use must contain at least 2 colors")
  4308. cols <- cols
  4309. if (rev.cols) cols <- rev(cols)
  4310. } else if (isTRUE(viridis)) {
  4311. if (!requireNamespace("viridis", quietly = TRUE))
  4312. stop("Package 'viridis' is required when viridis = TRUE")
  4313. cols <- viridis::viridis(9, option = viridis.opt)
  4314. if (rev.cols) cols <- rev(cols)
  4315. } else {
  4316. pal <- list(
  4317. hotspot = c("navy", "lightblue", "yellow", "orange", "red"),
  4318. rainbow = c("blue", "green", "yellow", "orange", "red")
  4319. )
  4320. if (theme.cols %in% names(pal)) {
  4321. cols <- pal[[theme.cols]]
  4322. } else {
  4323. cols <- RColorBrewer::brewer.pal(9, theme.cols)
  4324. }
  4325. if (rev.cols) cols <- rev(cols)
  4326. }
  4327. # Point size
  4328. raster <- raster %||% (ncol(object) > 2e5)
  4329. if (is.null(pt.size)) {
  4330. pt.size <- if (raster) 1 else min(1583 / ncol(object), 1)
  4331. }
  4332. ## Global max for merged legend
  4333. global_max <- NA
  4334. if (merge.leg && is.null(split.by) && length(features) > 1 && !blend) {
  4335. expr_data <- Seurat::FetchData(object, vars = features, layer = layer)
  4336. global_max <- max(expr_data, na.rm = TRUE)
  4337. }
  4338. # Plot
  4339. plt <- Seurat::FeaturePlot(
  4340. object = object,
  4341. features = features,
  4342. reduction = reduction,
  4343. order = order,
  4344. pt.size = pt.size,
  4345. raster = raster,
  4346. raster.dpi = raster.dpi,
  4347. split.by = split.by,
  4348. combine = combine,
  4349. blend = blend,
  4350. label = label,
  4351. ncol = ncol,
  4352. ...
  4353. )
  4354. # Apply color scale (always)
  4355. # if (!blend) {
  4356. # if (merge.leg && is.null(split.by) && length(features) > 1) {
  4357. # # Shared global scale
  4358. # plt <- plt & scale_color_gradientn(
  4359. # colors = cols,
  4360. # limits = c(na.cutoff, global_max),
  4361. # na.value = na.col
  4362. # )
  4363. # } else {
  4364. # # Per-feature scale
  4365. # plt <- plt & scale_color_gradientn(
  4366. # colors = cols,
  4367. # limits = c(na.cutoff, NA),
  4368. # na.value = na.col,
  4369. # name = NULL
  4370. # )
  4371. # }
  4372. # }
  4373. # Determine palette behavior
  4374. like <- isTRUE(viridis) || theme.cols %in% c("hotspot", "rainbow")
  4375. # Brewer palettes should gray-out low expression
  4376. if (!like && !blend) {
  4377. plt <- plt & scale_color_gradientn(
  4378. colors = cols,
  4379. limits = c(na.cutoff, NA),
  4380. oob = scales::censor, # force < cutoff → NA
  4381. na.value = na.col, # show gray
  4382. name = NULL
  4383. )} else if (!blend) {
  4384. # No gray background
  4385. plt <- plt & scale_color_gradientn(
  4386. colors = cols,
  4387. limits = c(na.cutoff, NA),
  4388. oob = scales::squish, # clip instead of NA
  4389. na.value = NA
  4390. )
  4391. }
  4392. # Theme
  4393. plt <- plt & plot_theme(theme.style = theme.style, font.size = font.size, ...) &
  4394. theme(plot.title = element_text(
  4395. hjust = 0.5, size = font.size + 2, face = "bold.italic"
  4396. ))
  4397. # ONLY add colorbar guide for non-blend plots
  4398. if (!blend) {
  4399. plt <- plt & guides(
  4400. color = guide_colorbar(
  4401. frame.colour = "black",
  4402. ticks.colour = "black"
  4403. )
  4404. )
  4405. }
  4406. # Manage axes
  4407. if (!axes) {
  4408. plt <- plt & Seurat::NoAxes()
  4409. }
  4410. ## Merge legend
  4411. if (merge.leg && is.null(split.by) && length(features) > 1) {
  4412. plt <- Seurat::CombinePlots(plots = plt, legend = "right", ncol = ncol)
  4413. }
  4414. return(plt)
  4415. }))
  4416. }
  4417. #' Generate Expression Heatmaps for Stage or Celltype–Sample Modes
  4418. #'
  4419. #' This function generates heatmaps from a Seurat object using either:
  4420. #' 1) **Stage mode**: aggregated expression across developmental or experimental stages; or
  4421. #' 2) **Celltype–Sample mode**: paired annotation of cell types and samples.
  4422. #'
  4423. #' It supports z-score normalization, gene filtering, palette selection, optional
  4424. #' flipping of heatmaps, row/column label position control, and integrated saving
  4425. #' through a custom `save.fig()` function.
  4426. #'
  4427. #' @param object A Seurat object.
  4428. #' @param features Character vector of gene names to include.
  4429. #' @param mode Plot mode: `"stage"` (default) or `"celltype-sample"`.
  4430. #' @param assay Assay to use (default `"RNA"`).
  4431. #' @param slot Data slot to extract (default `"counts"`).
  4432. #' @param zlim Numeric vector defining z-score color scaling boundaries
  4433. #' (default `c(-2, 0, 2)`).
  4434. #' @param fontsize Base font size for labels.
  4435. #' @param palette Color palette. Either `"viridis"` or manual colors.
  4436. #' @param manual.colors Colors used when `palette != "viridis"`.
  4437. #' @param flip.heatmap Logical. If TRUE, transpose the heatmap and switch label sides.
  4438. #' @param group.by Metadata column used for averaging in `"stage"` mode.
  4439. #' @param column.title Optional column title for the heatmap.
  4440. #' @param celltype.col Metadata column defining cell types (for `"celltype-sample"` mode).
  4441. #' @param sample.col Metadata column defining samples (for `"celltype-sample"` mode).
  4442. #' @param celltype.palette Named color vector for cell types.
  4443. #' @param sample.palette Named color vector for samples.
  4444. #' @param show.annotation.legend Logical; show annotation legends (default TRUE).
  4445. #' @param row.names.side Side for row names: `"left"` or `"right"` (default `"left"`).
  4446. #' @param column.names.side Side for column names: `"top"` or `"bottom"` (default `"top"`).
  4447. #' @param row.names.rot Rotation angle of row labels.
  4448. #' @param column.names.rot Rotation angle of column labels.
  4449. #' @param output.pdf File path to save heatmap as PDF. If NULL, no file is saved.
  4450. #' @param pdf.width PDF width (inches).
  4451. #' @param pdf.height PDF height (inches).
  4452. #' @param heatmap.legend.side Side for heatmap legend.
  4453. #' @param annotation.legend.side Side for annotation legend.
  4454. #'
  4455. #' @details
  4456. #' The function uses Seurat's `AverageExpression()` to compute aggregated values,
  4457. #' applies per-gene z-scoring, and uses `ComplexHeatmap` for rendering.
  4458. #'
  4459. #' If `flip.heatmap = TRUE`, the matrix is transposed and label sides are swapped
  4460. #' automatically to maintain readability.
  4461. #'
  4462. #' If `output.pdf` is provided, the function saves the heatmap using the user’s
  4463. #' custom `save.fig()` helper, ensuring directory creation and figure consistency.
  4464. #'
  4465. #' @return Invisibly returns the ComplexHeatmap object used for plotting.
  4466. #'
  4467. #' @examples
  4468. #' \dontrun{
  4469. #' expression_heatmap(
  4470. #' object,
  4471. #' features = c("GATA3", "SOX2"),
  4472. #' mode = "stage",
  4473. #' group.by = "PCW.group",
  4474. #' output.pdf = "plots/stage.heatmap.pdf"
  4475. #' )
  4476. #' }
  4477. #'
  4478. #' @import Seurat
  4479. #' @import dplyr
  4480. #' @import ComplexHeatmap
  4481. #' @import viridis
  4482. #' @import circlize
  4483. #' @import stringr
  4484. #' @import grid
  4485. #'
  4486. #' @export
  4487. #'
  4488. expression_heatmap <- function(
  4489. object,
  4490. features,
  4491. mode = c("stage", "celltype-sample"),
  4492. assay = "RNA",
  4493. slot = "counts",
  4494. zlim = c(-2, 0, 2),
  4495. fontsize = 12,
  4496. palette = "viridis",
  4497. manual.colors = c("purple","black","yellow"),
  4498. flip.heatmap = FALSE,
  4499. group.by = "PCW_group",
  4500. column.title = NULL,
  4501. celltype.col = "ann_level_2",
  4502. sample.col = "sample",
  4503. celltype.palette = NULL,
  4504. sample.palette = NULL,
  4505. show.annotation.legend = TRUE,
  4506. row.names.side = "left",
  4507. column.names.side = "top",
  4508. row.names.rot = 0,
  4509. column.names.rot = 90,
  4510. output.pdf = NULL,
  4511. pdf.width = 8,
  4512. pdf.height = 6,
  4513. heatmap.legend.side = "right",
  4514. annotation.legend.side = "right"
  4515. ){
  4516. require(Seurat)
  4517. require(ComplexHeatmap)
  4518. require(circlize)
  4519. require(viridis)
  4520. require(stringr)
  4521. require(grid)
  4522. mode <- match.arg(mode)
  4523. ## helpers
  4524. italic_labels <- function(x) {
  4525. as.expression(lapply(x, function(g) bquote(italic(.(g)))))
  4526. }
  4527. make_labels <- function(mat, flip) {
  4528. if (!flip) {
  4529. list(row = italic_labels(rownames(mat)), col = NULL)
  4530. } else {
  4531. list(row = NULL, col = italic_labels(colnames(mat)))
  4532. }
  4533. }
  4534. make_col_fun <- function() {
  4535. if (palette == "viridis") {
  4536. colorRamp2(seq(zlim[1], zlim[3], length.out = 256), viridis(256))
  4537. } else {
  4538. colorRamp2(zlim, manual.colors)
  4539. }
  4540. }
  4541. ## gene filtering
  4542. genes <- intersect(features, rownames(object))
  4543. if (length(genes) == 0) stop("None of the genes exist in Seurat object.")
  4544. sub <- subset(object, features = genes)
  4545. DefaultAssay(sub) <- assay
  4546. ## compute average expression
  4547. if (mode == "stage") {
  4548. avg <- AverageExpression(
  4549. sub, group.by = group.by, assays = assay, slot = slot
  4550. )[[assay]]
  4551. } else {
  4552. avg <- AverageExpression(
  4553. sub, group.by = c(celltype.col, sample.col),
  4554. assays = assay, slot = slot
  4555. )[[assay]]
  4556. }
  4557. ## z-score & ordering
  4558. avg <- avg[!rowSums(is.na(avg)), , drop = FALSE]
  4559. avg <- t(scale(t(avg)))
  4560. avg[is.na(avg)] <- 0
  4561. avg <- avg[order(apply(avg, 1, which.max)), , drop = FALSE]
  4562. ## annotations (celltype × sample)
  4563. top.anno <- NULL
  4564. if (mode == "celltype-sample") {
  4565. parts <- str_split_fixed(colnames(avg), "_", 2)
  4566. anno.df <- data.frame(
  4567. CellTypes = factor(parts[,1], levels = levels(object[[celltype.col]][,1])),
  4568. Samples = factor(parts[,2], levels = levels(object[[sample.col]][,1]))
  4569. )
  4570. rownames(anno.df) <- colnames(avg)
  4571. if (is.null(celltype.palette)) {
  4572. celltype.palette <- setNames(
  4573. viridis(length(levels(anno.df$CellTypes))),
  4574. levels(anno.df$CellTypes)
  4575. )
  4576. }
  4577. if (is.null(sample.palette)) {
  4578. sample.palette <- setNames(
  4579. viridis(length(levels(anno.df$Samples))),
  4580. levels(anno.df$Samples)
  4581. )
  4582. }
  4583. top.anno <- HeatmapAnnotation(
  4584. df = anno.df,
  4585. col = list(CellTypes = celltype.palette, Samples = sample.palette),
  4586. show_legend = show.annotation.legend,
  4587. annotation_name_gp = gpar(fontsize = fontsize, fontface = "bold")
  4588. )
  4589. }
  4590. ## flip heatmap
  4591. if (flip.heatmap) {
  4592. avg <- t(avg)
  4593. row.names.side <- ifelse(row.names.side == "left", "right", "left")
  4594. column.names.side <- ifelse(column.names.side == "top", "bottom", "top")
  4595. }
  4596. labels <- make_labels(avg, flip.heatmap)
  4597. ## build heatmap
  4598. ht_args <- list(
  4599. matrix = avg,
  4600. name = "Z-score",
  4601. top_annotation = top.anno,
  4602. cluster_rows = FALSE,
  4603. cluster_columns = FALSE,
  4604. col = make_col_fun(),
  4605. border = TRUE,
  4606. row_names_side = row.names.side,
  4607. column_names_side = column.names.side,
  4608. row_names_rot = row.names.rot,
  4609. column_names_rot = column.names.rot,
  4610. row_names_gp = gpar(fontsize = fontsize),
  4611. column_names_gp = gpar(fontsize = fontsize),
  4612. column_title = column.title,
  4613. heatmap_legend_param = list(
  4614. title = "Z-score",
  4615. title_gp = gpar(fontface = "bold"),
  4616. border = "black"
  4617. )
  4618. )
  4619. if (!flip.heatmap) {
  4620. ht_args$row_labels <- italic_labels(rownames(avg))
  4621. } else {
  4622. ht_args$column_labels <- italic_labels(colnames(avg))
  4623. }
  4624. ht <- do.call(Heatmap, ht_args)
  4625. ## draw / save
  4626. if (!is.null(output.pdf)) {
  4627. out.dir <- dirname(output.pdf)
  4628. if (!dir.exists(out.dir)) dir.create(out.dir, recursive = TRUE)
  4629. ComplexHeatmap::draw(
  4630. ht,
  4631. heatmap_legend_side = heatmap.legend.side,
  4632. annotation_legend_side = annotation.legend.side,
  4633. merge_legend = TRUE
  4634. )
  4635. save_heatmap(
  4636. ht = ComplexHeatmap::draw(
  4637. ht,
  4638. heatmap_legend_side = heatmap.legend.side,
  4639. annotation_legend_side = annotation.legend.side,
  4640. merge_legend = TRUE
  4641. ),
  4642. filename = output.pdf,
  4643. formats = c("pdf", "png", "tiff"),
  4644. width = pdf.width,
  4645. height = pdf.height,
  4646. dpi = 600
  4647. )
  4648. cat("Saved heatmap to:", output.pdf, "\n")
  4649. } else {
  4650. ComplexHeatmap::draw(
  4651. ht,
  4652. heatmap_legend_side = heatmap.legend.side,
  4653. annotation_legend_side = annotation.legend.side,
  4654. merge_legend = TRUE
  4655. )
  4656. }
  4657. }
  4658. #'#' Plot a Transcription Factor Regulatory Network
  4659. #'
  4660. #' Visualizes a transcription factor (TF) regulatory network using selected source TFs and their top N targets based on absolute regulatory strength (mor score).
  4661. #' Supports visual differentiation of positive and negative regulation, with shape and color coding for source vs. target nodes.
  4662. #'
  4663. #' @param net A data frame representing the TF regulatory network with columns: \code{source}, \code{target}, and \code{mor} (mode of regulation).
  4664. #' @param selected_sources A character vector of TFs to be used as source nodes.
  4665. #' @param n_targets Integer specifying the number of top targets to display per source (default: 5).
  4666. #' @param label_size Numeric; font size of node labels (default: 2).
  4667. #' @param node_size Numeric; size of node points (default: 15).
  4668. #' @param repel Logical; whether to repel node labels to avoid overlap using \code{geom_node_text} (default: FALSE).
  4669. #' @param layout_type Layout type for the network graph; passed to \code{ggraph::ggraph()} (default: "fr").
  4670. #' @param positive_color Color for positively regulated targets (default: "darkgreen").
  4671. #' @param negative_color Color for negatively regulated targets (default: "#e59866").
  4672. #' @param plot_title Title of the plot (default: "TF Regulatory Network").
  4673. #'
  4674. #' @return A ggplot2 object representing the regulatory network.
  4675. #'
  4676. #' @import ggraph
  4677. #' @import igraph
  4678. #' @import dplyr
  4679. #' @export
  4680. #'
  4681. #' @examples
  4682. #' \dontrun{
  4683. #' plot_tf_network(net = tf_network_df,
  4684. #' selected_sources = c("SOX2", "FOXA1"),
  4685. #' n_targets = 10,
  4686. #' repel = TRUE)
  4687. #' }
  4688. plot_tf_network <- function(net,
  4689. selected_sources,
  4690. n_targets = 5,
  4691. label_size = 2,
  4692. node_size = 15,
  4693. linewidth = 1.2,
  4694. repel = FALSE,
  4695. layout_type = "graphopt", # deterministic
  4696. max_edges = 500,
  4697. positive_color = "darkred",
  4698. negative_color = "navy",
  4699. plot_title = "TF Regulatory Network") {
  4700. require(ggraph)
  4701. require(igraph)
  4702. require(dplyr)
  4703. # Filter network
  4704. filtered_net <- net %>%
  4705. filter(source %in% selected_sources) %>%
  4706. group_by(source) %>%
  4707. top_n(n = n_targets, wt = abs(mor)) %>%
  4708. ungroup()
  4709. if (nrow(filtered_net) > max_edges) {
  4710. filtered_net <- filtered_net %>%
  4711. arrange(desc(abs(mor))) %>%
  4712. slice(1:max_edges)
  4713. warning(paste("Edge count exceeds", max_edges, "— truncating to top", max_edges, "edges."))
  4714. }
  4715. # Build graph
  4716. g <- graph_from_data_frame(filtered_net, directed = TRUE)
  4717. filtered_net$edge_color <- ifelse(filtered_net$mor > 0, "Positive", "Negative")
  4718. node_df <- data.frame(name = V(g)$name) %>%
  4719. mutate(node_type = ifelse(name %in% selected_sources, "Source", "Target"),
  4720. shape = ifelse(node_type == "Source", 22, 21),
  4721. edge_color = ifelse(node_type == "Source", "Source",
  4722. ifelse(name %in% filtered_net$target[filtered_net$mor > 0], "Positive", "Negative")),
  4723. label_color = ifelse(node_type == "Source", "black", "white"))
  4724. node_color_map <- c("Positive" = positive_color,
  4725. "Negative" = negative_color,
  4726. "Source" = "white")
  4727. # FIXED LAYOUT → stable, reproducible!
  4728. set.seed(42) # <-- makes layout identical every run
  4729. # Plot
  4730. p <- ggraph(g, layout = layout_type) +
  4731. geom_edge_link0(aes(edge_alpha = abs(mor), edge_colour = filtered_net$edge_color),
  4732. show.legend = TRUE, linewidth = linewidth) +
  4733. geom_node_point(aes(fill = factor(name, levels = node_df$name, labels = node_df$edge_color)),
  4734. size = node_size, shape = node_df$shape, show.legend = FALSE) +
  4735. geom_node_text(aes(label = name, color = factor(name, levels = node_df$name, labels = node_df$label_color)),
  4736. size = label_size, fontface = "bold.italic", repel = repel) +
  4737. # Legend
  4738. scale_edge_color_manual(
  4739. name = "Regulation",
  4740. values = c("Positive" = positive_color, "Negative" = negative_color),
  4741. guide = guide_legend(
  4742. override.aes = list(size = 6),
  4743. title.position = "top",
  4744. title.theme = element_text(size = 18, face = "bold"),
  4745. label.theme = element_text(size = 16)
  4746. )
  4747. ) +
  4748. scale_fill_manual(values = node_color_map, guide = "none") +
  4749. scale_color_manual(values = c("black", "white"), guide = "none") +
  4750. guides(edge_alpha = "none") +
  4751. theme_void() +
  4752. ggtitle(plot_title) +
  4753. theme(
  4754. plot.margin = unit(c(1,1,1,1), "cm"),
  4755. legend.text = element_text(size = 16),
  4756. legend.title = element_text(size = 18, face = "bold"),
  4757. legend.key.size = unit(1.4, "cm"),
  4758. legend.key.height = unit(1.4, "cm"),
  4759. legend.key.width = unit(1.4, "cm"),
  4760. legend.position = "right"
  4761. ) +
  4762. scale_x_discrete(expand = expansion(mult = c(0.1, 0.1))) +
  4763. scale_y_discrete(expand = expansion(mult = c(0.1, 0.1)))
  4764. return(p)
  4765. }
  4766. plot_tf_activity <- function(
  4767. seurat_object,
  4768. tf_list,
  4769. assay_activity = "tfsulm",
  4770. group.by = "seurat_clusters",
  4771. low_color = "navy",
  4772. high_color = "darkred",
  4773. zscore = TRUE,
  4774. zscore_mode = c("per_tf", "global"),
  4775. fontsize = 10,
  4776. legend.text.size = 10,
  4777. theme = "classic",
  4778. ncol = 1,
  4779. leg.pos = "bottom",
  4780. return_data = TRUE
  4781. ) {
  4782. require(Seurat)
  4783. require(ggplot2)
  4784. require(dplyr)
  4785. require(patchwork)
  4786. zscore_mode <- match.arg(zscore_mode)
  4787. DefaultAssay(seurat_object) <- assay_activity
  4788. found_activity <- intersect(tf_list, rownames(GetAssayData(seurat_object, slot = "data")))
  4789. if (length(found_activity) == 0)
  4790. stop("None of the TFs were found in the assay.")
  4791. # Compute TF activity data (ACCUMULATE CORRECTLY)
  4792. all_tf_data <- list()
  4793. for (tf in found_activity) {
  4794. df <- FetchData(seurat_object, vars = c(tf, group.by)) %>%
  4795. group_by(.data[[group.by]]) %>%
  4796. summarise(
  4797. mean_val = mean(.data[[tf]], na.rm = TRUE),
  4798. n_cells = n(),
  4799. .groups = "drop"
  4800. ) %>%
  4801. mutate(TF = tf)
  4802. df$zval <- if (zscore) scale(df$mean_val)[, 1] else df$mean_val
  4803. all_tf_data[[tf]] <- df
  4804. }
  4805. tf_activity_df <- bind_rows(all_tf_data)
  4806. # Global z-score limits (if requested)
  4807. if (zscore && zscore_mode == "global") {
  4808. global_abs_lim <- max(abs(tf_activity_df$zval), na.rm = TRUE)
  4809. } else {
  4810. global_abs_lim <- NULL
  4811. }
  4812. # Plotting
  4813. plot_list <- list()
  4814. total_tf <- length(tf_list)
  4815. for (i in seq_along(tf_list)) {
  4816. tf <- tf_list[i]
  4817. df <- tf_activity_df %>% filter(TF == tf)
  4818. if (nrow(df) == 0) {
  4819. p <- ggplot() + theme_void() + ggtitle(paste0(tf, " (not found)"))
  4820. } else {
  4821. show_x <- i == total_tf
  4822. p <- ggplot(df, aes(x = .data[[group.by]], y = zval, fill = zval)) +
  4823. geom_col(color = "black", linewidth = 0.2, width = 0.55) +
  4824. scale_fill_gradient2(
  4825. low = low_color,
  4826. mid = "white",
  4827. high = high_color,
  4828. midpoint = 0,
  4829. limits = if (!is.null(global_abs_lim)) c(-global_abs_lim, global_abs_lim) else NULL,
  4830. name = if (zscore) "z-score" else "Mean activity"
  4831. ) +
  4832. plot_theme(font.size = fontsize, theme = theme, axis.title.x.show = FALSE) +
  4833. theme(
  4834. axis.text.x = if (show_x) element_text(angle = 45, hjust = 1) else element_blank(),
  4835. axis.ticks.x = if (show_x) element_line() else element_blank(),
  4836. axis.title.x = if (show_x) element_text() else element_blank(),
  4837. plot.title = element_text(face = "bold.italic", hjust = 0.5)
  4838. ) +
  4839. ggtitle(tf) +
  4840. ylab(if (zscore) "z-score" else "Mean TF activity") +
  4841. xlab(if (show_x) group.by else "")
  4842. }
  4843. plot_list[[tf]] <- p
  4844. }
  4845. final_plot <- wrap_plots(
  4846. plot_list,
  4847. ncol = ncol,
  4848. guides = "collect",
  4849. axis_titles = "collect"
  4850. ) &
  4851. theme(
  4852. legend.position = leg.pos,
  4853. legend.title = element_text(size = legend.text.size),
  4854. legend.text = element_text(size = legend.text.size)
  4855. )
  4856. # Return
  4857. if (return_data) {
  4858. return(list(
  4859. plot = final_plot,
  4860. data = tf_activity_df
  4861. ))
  4862. } else {
  4863. return(final_plot)
  4864. }
  4865. }
  4866. #' Plot dominance score results
  4867. #'
  4868. #' @param df Data frame returned by `dominance_score()`$df
  4869. #' @param mode Plot mode: "bins", "high_cells", "top_genes"
  4870. #' @param ident.col Grouping column for "bins" and "high_cells" (default "ident")
  4871. #' @param stage.col Optional column to facet/stratify plots by, e.g., "stage" or "sample"
  4872. #' @param bin.col Column name for dominance bins (default "adjusted_bin")
  4873. #' @param gene.col Column name for genes (default "top_gene")
  4874. #' @param min.adjust Minimum adjusted dominance score (used in high_cells & top_genes)
  4875. #' @param min.top.expr Minimum top gene expression
  4876. #' @param max.n.genes Maximum number of expressed genes
  4877. #' @param top.n Number of top genes to show (mode = "top_genes")
  4878. #' @param colors Optional named vector of colors
  4879. #' @param font.size Base font size
  4880. #' @param theme ggplot theme name
  4881. #' @param leg.pos Legend position
  4882. #' @param x.angle X-axis text angle
  4883. #'
  4884. #' @return ggplot object
  4885. #' @export
  4886. plot_domscore <- function(
  4887. df,
  4888. mode = c("bins", "high_cells", "top_genes"),
  4889. ident.col = "ident",
  4890. stage.col = NULL,
  4891. bin.col = "adjusted_bin",
  4892. gene.col = "top_gene",
  4893. min.adjust = 1,
  4894. min.top.expr = 1,
  4895. max.n.genes = 3,
  4896. top.n = 20,
  4897. colors = NULL,
  4898. font.size = 10,
  4899. theme = "classic",
  4900. plot.ttl = NULL,
  4901. leg.pos = "top",
  4902. leg.dir = "horizontal",
  4903. x.angle = 45,
  4904. ncol = 2,
  4905. flip = TRUE,
  4906. facet.bg = FALSE,
  4907. ...
  4908. ) {
  4909. require(dplyr)
  4910. require(ggplot2)
  4911. mode <- match.arg(mode)
  4912. if (is.null(colors)) {
  4913. groups <- unique(df[[ident.col]])
  4914. colors <- setNames(
  4915. grDevices::colorRampPalette(RColorBrewer::brewer.pal(8, "Set2"))(length(groups)),
  4916. groups
  4917. )
  4918. }
  4919. # MODE: dominance bins
  4920. if (mode == "bins") {
  4921. plot.df <- df %>%
  4922. dplyr::filter(!is.na(.data[[bin.col]])) %>%
  4923. {
  4924. if (!is.null(stage.col) && stage.col %in% colnames(.)) {
  4925. group_by(., .data[[stage.col]], .data[[bin.col]], .data[[ident.col]])
  4926. } else {
  4927. group_by(., .data[[bin.col]], .data[[ident.col]])
  4928. }
  4929. } %>%
  4930. summarise(n = n(), .groups = "drop") %>%
  4931. {
  4932. if (!is.null(stage.col) && stage.col %in% colnames(.)) {
  4933. group_by(., .data[[stage.col]], .data[[bin.col]])
  4934. } else group_by(., .data[[bin.col]])
  4935. } %>%
  4936. mutate(perc = 100 * n / sum(n)) %>%
  4937. ungroup()
  4938. p <- ggplot(plot.df,
  4939. aes(x = .data[[bin.col]], y = perc, fill = .data[[ident.col]])) +
  4940. geom_col(color = "black", linewidth = 0.2, width=0.5) +
  4941. scale_fill_manual(values = colors) +
  4942. labs(x = "Dominance score", y = "Cell type proportion (%)", fill = "", title = plot.ttl) +
  4943. plot_theme(theme = theme, font.size = font.size, leg.pos = leg.pos,leg.dir = leg.dir, x.angle = 0)
  4944. }
  4945. # MODE: high-dominance cells
  4946. else if (mode == "high_cells") {
  4947. plot.df <- df %>%
  4948. filter(
  4949. adjust_score > min.adjust,
  4950. top_expr >= min.top.expr,
  4951. n_genes_expr <= max.n.genes
  4952. ) %>%
  4953. {
  4954. if (!is.null(stage.col) && stage.col %in% colnames(.)) {
  4955. group_by(., .data[[stage.col]], .data[[ident.col]])
  4956. } else group_by(., .data[[ident.col]])
  4957. } %>%
  4958. summarise(n_cells = n(), .groups = "drop")
  4959. x_col <- if (!is.null(stage.col) && stage.col %in% colnames(df)) stage.col else ident.col
  4960. p <- ggplot(plot.df,
  4961. aes(x = factor(.data[[x_col]]),
  4962. y = n_cells,
  4963. fill = .data[[ident.col]])) +
  4964. geom_col(color = "black", linewidth = 0.2, width = 0.5) +
  4965. scale_fill_manual(values = colors) +
  4966. labs(x = "", y = paste0("High-dominance cells\n(adjusted > ", min.adjust, ")"), fill = "", title = plot.ttl) +
  4967. plot_theme(theme = theme, font.size = font.size, leg.pos = leg.pos, leg.dir = leg.dir, x.angle = x.angle)
  4968. }
  4969. # MODE: top dominant genes
  4970. if (mode == "top_genes") {
  4971. high.df <- df %>%
  4972. filter(
  4973. adjust_score > min.adjust,
  4974. top_expr >= min.top.expr,
  4975. n_genes_expr <= max.n.genes,
  4976. !is.na(.data[[gene.col]])
  4977. )
  4978. high.df[[gene.col]] <- as.character(high.df[[gene.col]])
  4979. if (!is.null(stage.col) && stage.col %in% colnames(high.df)) {
  4980. plot.df <- high.df %>%
  4981. group_by(.data[[stage.col]], .data[[gene.col]]) %>%
  4982. summarise(n = n(), .groups = "drop") %>%
  4983. arrange(.data[[stage.col]], desc(n)) %>%
  4984. group_by(.data[[stage.col]]) %>%
  4985. slice_head(n = top.n) %>%
  4986. ungroup()
  4987. plot.df$n <- as.integer(plot.df$n)
  4988. p <- ggplot(plot.df, aes(x = reorder(.data[[gene.col]], -n), y = n)) + # <- descending
  4989. geom_col(fill = "steelblue", color = "black", linewidth = 0.2, width = 0.5) +
  4990. facet_wrap(as.formula(paste0("~", stage.col)), ncol = ncol) +
  4991. scale_y_continuous(breaks = scales::pretty_breaks()) +
  4992. labs(x = "Top dominant OR genes", y = "Number of high-dominance cells", title = plot.ttl) +
  4993. plot_theme(font.size = font.size, theme.style = theme, leg.pos = "none", x.angle = x.angle, facet.bg = facet.bg,...)
  4994. if (flip) p <- p + coord_flip()
  4995. } else {
  4996. plot.df <- high.df %>%
  4997. dplyr::count(!!rlang::sym(gene.col), sort = TRUE, name = "n") %>%
  4998. slice_head(n = top.n)
  4999. plot.df$n <- as.integer(plot.df$n)
  5000. p <- ggplot(plot.df, aes(x = reorder(!!rlang::sym(gene.col), -n), y = n)) + # <- descending
  5001. geom_col(fill = "steelblue", color = "black", linewidth = 0.2, width = 0.5) +
  5002. scale_y_continuous(breaks = scales::pretty_breaks()) +
  5003. labs(x = "", y = "Number of high-dominance cells", title = plot.ttl) +
  5004. plot_theme(font.size = font.size, theme.style = theme, leg.pos = "none", x.angle = x.angle,facet.bg = facet.bg,...)
  5005. if (flip) p <- p + coord_flip()
  5006. }
  5007. }
  5008. if (!is.null(colors)) p <- p + scale_fill_manual(values = colors)
  5009. return(p)
  5010. }
  5011. #'------------------------------------------------------------------------------
  5012. #'------------------------------------------------------------------------------
  5013. #'
  5014. #' Plot Heatmap Along Pseudotime
  5015. #'
  5016. #' Generates a heatmap of gene expression along pseudotime for selected lineages, with
  5017. #' flexible annotation and legend options. Supports automatic legend type detection
  5018. #' (discrete vs continuous), smooth scaling, z-score transformation, and flexible
  5019. #' legend title positioning.
  5020. #'
  5021. #' @param heatmap_matrices List of heatmap matrices produced by `identify_temporal_genes` or similar. Each element should have `expr_raw`, `ordered_cells`, `top_genes`.
  5022. #' @param seurat_obj Seurat object containing cell metadata.
  5023. #' @param lineage_name Character. Name of the lineage to plot. Defaults to first element of `heatmap_matrices`.
  5024. #' @param pseudotime_vec Numeric vector of pseudotime values. Defaults to sequential ordering of `ordered_cells`.
  5025. #' @param gene_list Character vector of genes to plot. Defaults to `hm$top_genes`.
  5026. #' @param annotation_col Column in `[email hidden]` to use for annotation. Default `"ann_level_2"`.
  5027. #' @param celltype_colors Named vector of colors for annotation clusters. If `NULL`, colors are auto-generated.
  5028. #' @param zscore_limits Numeric vector of length 3, c(min, mid, max), used for color scaling. Default c(-2,0,2).
  5029. #' @param pseudotime_palette Character. Palette name for pseudotime continuous scale. Default `"YlGnBu"`.
  5030. #' @param row_cluster_colors Not currently used. Placeholder for future row annotations.
  5031. #' @param font_size Font size for heatmap labels and legends. Default 10.
  5032. #' @param output_pdf Path to save heatmap. If `NULL`, heatmap is drawn on screen.
  5033. #' @param fig_name Filename for saving heatmap. Default uses `plot_title`.
  5034. #' @param pdf_width Width of saved PDF/PNG. Default 6.
  5035. #' @param pdf_height Height of saved PDF/PNG. Default 10.
  5036. #' @param plot_title Title of heatmap. Default `NULL`.
  5037. #' @param show_annotation_legend Logical. Show legend for top annotations. Default `TRUE`.
  5038. #' @param smooth_df Degrees of freedom for smoothing spline. Default 3.
  5039. #' @param legend_dir `"vertical"` or `"horizontal"`. Orientation of legend. Default `"vertical"`.
  5040. #' @param ht_legend_side Side of heatmap legend (`"right"` or `"left"`). Default `"right"`.
  5041. #' @param ann_legend_side Side of top annotation legend (`"top"` or `"right"`). Default `"right"`.
  5042. #' @param legend_width_cm Width of legends in cm. Default 3.
  5043. #' @param cont_legend_cm Height of continuous legends in cm. Default 3.
  5044. #' @param merge_legends Logical. Merge annotation and heatmap legends. Default `FALSE`.
  5045. #' @param center_legend_titles Logical. Center all heatmap legend titles. Default `TRUE`.
  5046. #' @param center_cells_title Logical. Center "Cells" annotation legend title. Default `TRUE`.
  5047. #' @param center_pt_title Logical. Center "Pseudotime" annotation legend title. Default `TRUE`.
  5048. #' @param cells_title_pos Custom position for "Cells" title (overrides centering). Default `NULL`.
  5049. #' @param pt_title_pos Custom position for "Pseudotime" title (overrides centering). Default `NULL`.
  5050. #' @param hm_title_pos Custom position for heatmap legend title. Default `NULL`.
  5051. #'
  5052. #' @return `ComplexHeatmap` object invisibly. If `output_pdf` is provided, the heatmap is saved as PNG.
  5053. #' @export
  5054. #'
  5055. #' @examples
  5056. #' plot_heatmap_pseudotime(
  5057. #' heatmap_matrices = l1$heatmap_matrices,
  5058. #' seurat_obj = sub,
  5059. #' lineage_name = "Lineage1",
  5060. #' pseudotime_vec = pt_vec,
  5061. #' gene_list = gene_list,
  5062. #' celltype_colors = cols,
  5063. #' font_size = 8,
  5064. #' pdf_width = 4,
  5065. #' pdf_height = 6,
  5066. #' output_pdf = file.path(dir.results),
  5067. #' plot_title = "Neuronal lineage",
  5068. #' legend_dir = "vertical",
  5069. #' ht_legend_side = "right",
  5070. #' ann_legend_side = "right",
  5071. #' annotation_col = "ann2",
  5072. #' merge_legends = TRUE,
  5073. #' center_legend_titles = TRUE,
  5074. #' center_cells_title = TRUE,
  5075. #' center_pt_title = FALSE,
  5076. #' cells_title_pos = "topcenter",
  5077. #' pt_title_pos = "leftcenter",
  5078. #' hm_title_pos = "topcenter"
  5079. #' )
  5080. plot_heatmap_pseudotime <- function(
  5081. heatmap_matrices,
  5082. seurat_obj,
  5083. lineage_name = NULL,
  5084. pseudotime_vec = NULL,
  5085. gene_list = NULL,
  5086. annotation_col = "ann_level_2",
  5087. celltype_colors = NULL,
  5088. zscore_limits = c(-2, 0, 2),
  5089. pseudotime_palette = "YlGnBu",
  5090. row_cluster_colors = NULL,
  5091. font_size = 10,
  5092. output_pdf = NULL,
  5093. fig_name = paste(plot_title),
  5094. pdf_width = 6,
  5095. pdf_height = 10,
  5096. plot_title = NULL,
  5097. show_annotation_legend = TRUE,
  5098. smooth_df = 3,
  5099. legend_dir = "vertical",
  5100. ht_legend_side = "right",
  5101. ann_legend_side = "right",
  5102. legend_width_cm = 3,
  5103. cont_legend_cm = 3,
  5104. merge_legends = FALSE,
  5105. center_legend_titles = TRUE,
  5106. center_cells_title = TRUE,
  5107. center_pt_title = TRUE,
  5108. cells_title_pos = NULL,
  5109. pt_title_pos = NULL,
  5110. hm_title_pos = NULL,
  5111. file_path = file.path(outdir, fig_name)
  5112. ){
  5113. require(ComplexHeatmap)
  5114. require(circlize)
  5115. require(RColorBrewer)
  5116. # --- Setup lineage and genes
  5117. if(is.null(lineage_name)) lineage_name <- names(heatmap_matrices)[1]
  5118. if(!lineage_name %in% names(heatmap_matrices)) stop("Lineage not found")
  5119. hm <- heatmap_matrices[[lineage_name]]
  5120. if(is.null(gene_list)) gene_list <- hm$top_genes
  5121. cells_hm <- hm$ordered_cells
  5122. expr_mat <- as.matrix(hm$expr_raw[gene_list, cells_hm, drop = FALSE])
  5123. # --- Smooth
  5124. if(!is.null(smooth_df) && smooth_df > 0){
  5125. expr_mat <- t(apply(expr_mat, 1, function(x){
  5126. if(sd(x) == 0) return(x)
  5127. tryCatch(smooth.spline(x, df = smooth_df)$y, error = function(e) x)
  5128. }))
  5129. }
  5130. # --- Z-score scaling
  5131. row_means <- rowMeans(expr_mat, na.rm = TRUE)
  5132. row_sds <- apply(expr_mat, 1, sd, na.rm = TRUE)
  5133. row_sds[row_sds == 0] <- 1
  5134. expr_mat_scaled <- (expr_mat - row_means) / row_sds
  5135. # --- Order genes by pseudotime peak
  5136. gene_peak <- apply(expr_mat_scaled, 1, which.max)
  5137. expr_mat_scaled <- expr_mat_scaled[order(gene_peak), ]
  5138. # --- Clusters and annotation
  5139. clusters <- [email hidden][cells_hm, annotation_col]
  5140. if(!is.factor(clusters)){
  5141. cluster_order <- unique(clusters)
  5142. clusters <- factor(clusters, levels = cluster_order)
  5143. } else {
  5144. cluster_order <- levels(clusters)
  5145. }
  5146. clusters[is.na(clusters)] <- "Unknown"
  5147. if(is.null(celltype_colors)){
  5148. celltype_colors <- setNames(
  5149. colorRampPalette(brewer.pal(8,"Dark2"))(length(cluster_order)),
  5150. cluster_order
  5151. )
  5152. }
  5153. if(is.null(pseudotime_vec)) pseudotime_vec <- seq_along(cells_hm)
  5154. pseudotime <- pseudotime_vec[cells_hm]
  5155. pt_col_fun <- colorRamp2(
  5156. seq(min(pseudotime), max(pseudotime), length.out = 100),
  5157. viridis::viridis(100, option = "C")
  5158. )
  5159. # --- Helper to resolve title positions
  5160. resolve_title_pos <- function(title_pos, center, is_discrete, legend_dir){
  5161. if(is_discrete){
  5162. if(!is.null(title_pos)) return(title_pos)
  5163. if(legend_dir == "vertical") return("left") else return("top")
  5164. } else {
  5165. if(!is.null(title_pos)) return(title_pos)
  5166. if(center){
  5167. if(legend_dir == "vertical") return("leftcenter-rot") else return("topcenter")
  5168. } else {
  5169. if(legend_dir == "vertical") return("lefttop-rot") else return("topcenter")
  5170. }
  5171. }
  5172. }
  5173. is_cells_discrete <- is.factor(clusters)
  5174. is_pt_discrete <- !is.numeric(pseudotime)
  5175. cells_pos <- resolve_title_pos(cells_title_pos, center_cells_title, is_cells_discrete, legend_dir)
  5176. pt_pos <- resolve_title_pos(pt_title_pos, center_pt_title, is_pt_discrete, legend_dir)
  5177. top_annot <- HeatmapAnnotation(
  5178. "Cells" = factor(clusters, levels = cluster_order),
  5179. "Pseudotime" = pseudotime,
  5180. col = list("Cells" = celltype_colors, "Pseudotime" = pt_col_fun),
  5181. show_legend = show_annotation_legend,
  5182. annotation_legend_param = list(
  5183. "Cells" = list(
  5184. border = "black",
  5185. labels_gp = gpar(fontsize = font_size),
  5186. title_gp = gpar(
  5187. fontsize = font_size,
  5188. fontface = "bold",
  5189. just = if(center_cells_title) "center" else "left"
  5190. ),
  5191. title_position = cells_pos,
  5192. legend_direction = legend_dir,
  5193. legend_width = unit(legend_width_cm, "cm"),
  5194. legend_height = unit(cont_legend_cm, "cm"),
  5195. nrow = if(legend_dir == "horizontal") 1 else NULL
  5196. ),
  5197. "Pseudotime" = list(
  5198. border = "black",
  5199. labels_gp = gpar(fontsize = font_size),
  5200. title_gp = gpar(
  5201. fontsize = font_size,
  5202. fontface = "bold",
  5203. just = if(center_pt_title) "center" else "left"
  5204. ),
  5205. title_position = pt_pos,
  5206. legend_direction = legend_dir,
  5207. legend_width = unit(legend_width_cm, "cm"),
  5208. legend_height = unit(cont_legend_cm, "cm")
  5209. )
  5210. ),
  5211. annotation_name_gp = gpar(fontsize = font_size, fontface = "bold")
  5212. )
  5213. ht <- Heatmap(
  5214. expr_mat_scaled,
  5215. name = "Z-score",
  5216. col = colorRamp2(seq(-2, 2, length = 11), rev(brewer.pal(11, "Spectral"))),
  5217. cluster_rows = FALSE,
  5218. cluster_columns = FALSE,
  5219. show_column_names = FALSE,
  5220. show_row_names = TRUE,
  5221. row_names_gp = gpar(fontsize = font_size, fontface="italic"),
  5222. top_annotation = top_annot,
  5223. row_title_rot = 0,
  5224. cluster_row_slices = FALSE,
  5225. show_row_dend = FALSE,
  5226. column_title = plot_title,
  5227. column_title_gp = gpar(fontsize = font_size+2, fontface="bold"),
  5228. heatmap_legend_param = list(
  5229. title = "Z-score",
  5230. legend_direction = legend_dir,
  5231. title_position = resolve_title_pos(hm_title_pos, center_legend_titles, FALSE, legend_dir),
  5232. legend_width = unit(legend_width_cm, "cm"),
  5233. legend_height = unit(cont_legend_cm, "cm"),
  5234. labels_gp = gpar(fontsize = font_size),
  5235. title_gp = gpar(
  5236. fontsize = font_size,
  5237. fontface = "bold",
  5238. just = if(center_legend_titles) "center" else "left"
  5239. ),
  5240. border = "black"
  5241. )
  5242. )
  5243. if(!is.null(output_pdf)){
  5244. ComplexHeatmap::draw(ht,
  5245. heatmap_legen

000-functions.R at commit 2bd43a8, under GPL-3.0 · at the source

Overview

  1. Univ. Lille, Inserm, CHU Lille, Lille Neuroscience & Cognition, UMR-S 1172,Lille, France
  2. Université Côte d’Azur, CNRS, INSERM, Institut de Pharmacologie Moléculaire et Cellulaire, IHU RespirERA, 3IA Côte d’Azur,Sophia Antipolis, France
Institutions: Centre Hospitalier Universitaire de Lille (France); Inserm (France)
Journal: Nature communications, volume 17, issue 1, article 3537
Dates: received 15 September 2025; accepted 16 March 2026; published online 17 April 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41467-026-71595-6 · PMID 41997969 · PMCID PMC13090377 · OpenAlex W7154687751
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: genetics / omics (modality), histology / microscopy (modality), human (organism)
Methods: Statistics, Smoothing, state filtering, decompositions
Keywords: Morphogenesis, Neurogenesis
MeSH: Olfactory Mucosa*, Cell Differentiation, Female, Fetus, Gene Expression Regulation, Developmental, Humans, In Situ Hybridization, Fluorescence, Male, Neurodevelopment, Neurogenesis, Olfactory Receptor Neurons, Receptors, Odorant, Single-Cell Analysis, Single-Cell Gene Expression Analysis, Spatial Transcriptomics, Transcriptome (* major topic)
Topic: Olfactory and Sensory Function Studies (Sensory Systems, Neuroscience), according to OpenAlex
Funding: Agence Nationale de la Recherche (French National Research Agency) (ANR-24-CHBS-0002, ANR-19-CE14–0027, ANR-23-IAHU-0007)
Citations: cited by 1 paper (Europe PMC); 67 references in the paper

Abstract

The human nasal region arises from neural crest and placodal lineages, yet its early development remains poorly understood owing to limited fetal tissue access and structural complexity. Here we present an integrated single-nucleus and spatial transcriptomic atlas of the human fetal nasal region, generated from male and female fetuses between 7 and 12 post-conceptional weeks. Single-nucleus RNA sequencing [snRNA-seq] resolved 32 distinct cell types, while integration with multiplexed error-robust fluorescence in situ hybridization (MERFISH) enabled spatial and temporal mapping of gene expression dynamics across the olfactory epithelium (OE) and adjacent tissues. We identify markers of olfactory sensory neuron differentiation and pathways governing epithelial patterning and OE morphogenesis. Notably, spatially resolved snRNA-seq profiles of 169 olfactory receptor genes reveal molecular support for the “one neuron-one receptor” principle already in the first trimester. Together, this work establishes a molecular and spatial framework of early human olfactory development and provides a resource for studies of sensory neurogenesis and congenital disorders.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 26 matches between paragraphs and lines of code.

ymbouamboua/HuDeCa

License: GPL-3.0
State: the link answers, verified on 29 September 2026
Evidence: files inventoried
Commit: 2bd43a819a472b3982789e8d5d4e0bd672530a1e, 26 February 2026
Languages: R (18), Shell (3), Jupyter (2)
Size: 28 files, 23 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, 18 notebooks
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: tidyverse (9 files), Seurat (6 files), patchwork (3 files), circlize (2 files), ComplexHeatmap (2 files), ggplot2 (2 files), Matplotlib (2 files), mgcv (2 files), NumPy (2 files), pandas (2 files), reshape2 (2 files), BCFtools (1 file), caret (1 file), cowplot (1 file), ggpubr (1 file), Harmony (1 file), igraph (1 file), lme4 (1 file), Plotly (1 file), seaborn (1 file), SingleCellExperiment (1 file)
Availability: 1 check, the latest on 29 September 2026: the link answers
  • 29 September 2026: the link answers
25 files

cobioda/human_fetal_olfactory_system

License: none: the authors keep all their rights
State: the link answers, verified on 29 September 2026
Evidence: files inventoried
Commit: eb07faa5e0c6bd200b885eb8ae4f5646d89d9035, 4 December 2025
Languages: Jupyter (16)
Size: 18 files, 16 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, 16 notebooks
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: pandas (11 files), Scanpy (11 files), anndata (10 files), Matplotlib (9 files), NumPy (8 files), seaborn (8 files), CuPy (1 file), statannotations (1 file)
Availability: 1 check, the latest on 29 September 2026: the link answers
  • 29 September 2026: the link answers
12 files

Code availability

Custom scripts and codes used for snRNA-seq analysis can be found at https://github.com/ymbouamboua/HuDeCa. Python scripts for re-analysis and figures production of MERFISH experiments can be found at https://github.com/cobioda/human_fetal_olfactory_system.

Reproduced under the paper's license (CC BY), from the paper cited above.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 35 scripts, each with its path and the digest of its content;
  • 26 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Data availability

All data generated or analyzed in the current study are included in the article and its Supplementary Figs. and Supplementary Data. The raw snRNA-seq datasets generated during the current study have been deposited in the European Genome/Phenome Archive (https://ega-archive.org/datasets/EGAD50000001712) and are available through controlled access due to privacy and consent restrictions related to human genomic data. Access to EGA archive datasets can be obtained by formal application to the Data Access Committee (DAC). Each DAC requires users/applicants to sign a Data Access Agreement (DAA), which details the terms and conditions of use for each dataset. An interactive Shiny application associated with processed human snRNA-seq data has been deposited on Zenodo (10.5281/zenodo.18245692). MERFISH data have been deposited in Gene Expression Omnibus under accession code GSE303809 (https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE303809). Source data are provided with this paper.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 29 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 10 authors, 2 keywords, 16 MeSH terms, 1 funder, 67 references.

Cite

This paper

Mbouamboua, Y., Lebrigand, K., Nampoothiri, S., Couralet, M., Arguel, M.-J., Cotellessa, L., Allet, C., Prevot, V., Barbry, P., & Giacobini, P. (2026). A single-cell and spatial atlas of early human olfactory development. Nature communications, 17(1), 3537. https://doi.org/10.1038/s41467-026-71595-6

BibTeX

@article{mbouamboua2026single,
author = {Mbouamboua, Yvon and Lebrigand, Kevin and Nampoothiri, Sreekala and Couralet, Marie and Arguel, Marie-Jeanne and Cotellessa, Ludovica and Allet, Cécile and Prevot, Vincent and Barbry, Pascal and Giacobini, Paolo},
title = {{A single-cell and spatial atlas of early human olfactory development}},
journal = {Nature communications},
year = {2026},
month = apr,
volume = {17},
number = {1},
pages = {3537},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-71595-6},
url = {https://doi.org/10.1038/s41467-026-71595-6},
pmid = {41997969},
pmcid = {PMC13090377}
}

RIS

TY - JOUR
AU - Mbouamboua, Yvon
AU - Lebrigand, Kevin
AU - Nampoothiri, Sreekala
AU - Couralet, Marie
AU - Arguel, Marie-Jeanne
AU - Cotellessa, Ludovica
AU - Allet, Cécile
AU - Prevot, Vincent
AU - Barbry, Pascal
AU - Giacobini, Paolo
TI - A single-cell and spatial atlas of early human olfactory development
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/04/17
VL - 17
IS - 1
SP - 3537
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-71595-6
UR - https://doi.org/10.1038/s41467-026-71595-6
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-71595-6",
"type": "article-journal",
"title": "A single-cell and spatial atlas of early human olfactory development",
"container-title": "Nature communications",
"author": [
{
"family": "Mbouamboua",
"given": "Yvon"
},
{
"family": "Lebrigand",
"given": "Kevin"
},
{
"family": "Nampoothiri",
"given": "Sreekala"
},
{
"family": "Couralet",
"given": "Marie"
},
{
"family": "Arguel",
"given": "Marie-Jeanne"
},
{
"family": "Cotellessa",
"given": "Ludovica"
},
{
"family": "Allet",
"given": "Cécile"
},
{
"family": "Prevot",
"given": "Vincent"
},
{
"family": "Barbry",
"given": "Pascal"
},
{
"family": "Giacobini",
"given": "Paolo"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "3537",
"DOI": "10.1038/s41467-026-71595-6",
"PMID": "41997969",
"PMCID": "PMC13090377",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-71595-6",
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
17
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1016/j.xcrm.2026.102766 [code]
A longitudinal single-cell and spatial multiomic atlas of pediatric high-grade glioma.
Journal: Cell reports. Medicine
In common: Harmony, SingleCellExperiment, anndata, 17 other tools, genetics / omics, 3 references
[2] doi:10.1038/s41467-026-75722-1 [code]
Single-nucleus analysis of the adult human olfactory epithelium uncovers shared neurogenesis programs with the brain.
Journal: Nature communications
In common: SingleCellExperiment, anndata, circlize, 12 other tools, genetics / omics, 6 references
[3] doi:10.1002/imt2.70163 [code]
Spatial multi-omics unveils sphingolipid metabolic reprogramming within the retinal pathological niche.
Journal: iMeta
In common: Harmony, SingleCellExperiment, anndata, 16 other tools, genetics / omics, 1 reference
[4] doi:10.1038/s41467-026-74598-5 [code]
A synaptoid connectome differentiates tanycytic subpopulations and underlies neuroglial communication and neuroendocrine regulation.
Journal: Nature communications
In common: Seurat, cowplot, ggplot2, 1 other tool, 4 authors
[5] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: BCFtools, CuPy, caret, 15 other tools
[6] doi:10.3390/ijms27104466 [code]
Uncovering the Key Circuit FOSL2/FOS/EGR3/EGR1, Contributing to the Hyperexcitability of Excitatory Neurons in the Epileptic Temporal Cortex and Hippocampus.
Journal: International journal of molecular sciences
In common: caret, anndata, igraph, 13 other tools, genetics / omics, 2 references
[7] doi:10.1016/j.xcrm.2026.102651 [code]
Integrative CSF profiling identifies disease-specific immune responses in leptomeningeal disease.
Journal: Cell reports. Medicine
In common: Harmony, SingleCellExperiment, anndata, 15 other tools, genetics / omics
[8] doi:10.1038/s41586-026-10214-2 [code]
Multidimensional profiling of heterogeneity in supratentorial ependymomas.
Journal: Nature
In common: Harmony, SingleCellExperiment, anndata, 15 other tools, genetics / omics
[9] doi:10.1093/bioinformatics/btag592 [code]
Network-based stratification of allele-specific expression reveals patient subgroups in Huntington's disease.
Journal: Bioinformatics (Oxford, England)
In common: BCFtools, mgcv, igraph, 14 other tools, genetics / omics, 1 reference
[10] doi:10.1038/s41586-026-10629-x [code]
Whole-genome duplication shaped cell-type evolution in the vertebrate brain.
Journal: Nature
In common: Harmony, anndata, igraph, 14 other tools, genetics / omics, 1 reference

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.