Publication-Ready UMAP Plot

Authors

[Editor] Shixiang Wang;

[Contributors] Claude Code — AI-generated, independently AI-reviewed.

Modified

2026-10-27

🤖 AI Skill — Download the Bizard unified skill for your AI assistant ⬇️ Download Skill ZIP
🔌 MCP Server — Let AI agents (Claude Code, Cursor, …) query Bizard live: claude mcp add bizard -- npx @openbiox/bizard-mcp — or the free hosted instance (no install, faster from mainland China): claude mcp add --transport http bizard http://biotree.top:38123/bizard-mcp/mcp 📖 MCP Docs
📖 Cite Bizard — if Bizard helps your research, please cite: Li, K., Zheng, H., Huang, K., et al. (2026). Bizard: A Community-Driven Platform for Accelerating and Enhancing Biomedical Data Visualization. iMetaMed, e70038. 🔗 DOI / BibTeX

A plain DimPlot is rarely what a journal figure needs. Reviewers expect UMAP embeddings that carry precise cluster labels, consistent factor ordering, restrained axes, and — in the style of high-impact single-cell papers — soft outlines that wrap each cell population. This tutorial assembles a complete, publication-ready UMAP workflow for Seurat objects:

  1. Baseline panels: labelled UMAP, faceted UMAP, and a patchwork composite with journal-style letter tags.
  2. Scheme 1 — tidydr arrow axes: L-shaped arrow axes without grid lines; the safe, universal SCI style.
  3. Scheme 2 — cloud outlines (scRNAtoolVis::clusterCornerAxes): corner axes plus translucent “cloud” halos around clusters; the popular CNS-sub-journal look.
  4. Scheme 3 — dashed hulls (ggunchull::stat_unchull): crisp dashed non-convex hulls with centred bold labels; the polished review-article style.
  5. Scheme 4 — fine masks (mascarade::generateMask): density-based contours that hug high-density embeddings; for high-resolution, large-atlas figures.
  6. Bonus — 3D density landscape: an interactive three-dimensional kernel-density “terrain” of the embedding with plotly.

All four schemes draw from one simulated PBMC-style Seurat object, so every panel is fully reproducible in the browser.

Example

Publication-ready UMAP styles DEMO: tidydr, scRNAtoolVis, ggunchull, mascarade

Setup

  • System Requirements: Cross-platform (Linux/MacOS/Windows)

  • Programming Language: R

  • Dependent packages: Seurat, ggplot2, dplyr, stringr, patchwork, RColorBrewer, ggrepel, tidydr, scRNAtoolVis, ggunchull, mascarade, sessioninfo, plotly

# CRAN dependencies
if (!requireNamespace("Seurat", quietly = TRUE)) install.packages("Seurat")
if (!requireNamespace("ggplot2", quietly = TRUE)) install.packages("ggplot2")
if (!requireNamespace("dplyr", quietly = TRUE)) install.packages("dplyr")
if (!requireNamespace("stringr", quietly = TRUE)) install.packages("stringr")
if (!requireNamespace("patchwork", quietly = TRUE)) install.packages("patchwork")
if (!requireNamespace("RColorBrewer", quietly = TRUE)) install.packages("RColorBrewer")
if (!requireNamespace("ggrepel", quietly = TRUE)) install.packages("ggrepel")
if (!requireNamespace("tidydr", quietly = TRUE)) install.packages("tidydr")
if (!requireNamespace("mascarade", quietly = TRUE)) install.packages("mascarade")
if (!requireNamespace("plotly", quietly = TRUE)) install.packages("plotly")

# GitHub dependencies
if (!requireNamespace("remotes", quietly = TRUE)) install.packages("remotes")
if (!requireNamespace("scRNAtoolVis", quietly = TRUE)) {
  remotes::install_github("junjunlab/scRNAtoolVis")
}
if (!requireNamespace("ggunchull", quietly = TRUE)) {
  remotes::install_github("sajuukLyu/ggunchull")
}

# Load packages
library(Seurat)
library(ggplot2)
library(dplyr)
library(stringr)
library(patchwork)
library(RColorBrewer)
library(ggrepel)
library(tidydr)
library(scRNAtoolVis)
library(ggunchull)
library(mascarade)
library(plotly)
sessioninfo::session_info("attached")
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.6.1 (2026-06-24)
 os       Ubuntu 24.04.5 LTS
 system   x86_64, linux-gnu
 ui       X11
 language (EN)
 collate  C.UTF-8
 ctype    C.UTF-8
 tz       UTC
 date     2026-10-27
 pandoc   3.1.3 @ /usr/bin/ (via rmarkdown)
 quarto   1.10.19 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 ! package      * version    date (UTC) lib source
 P dplyr        * 1.2.1      2026-04-03 [?] RSPM
 P forcats      * 1.0.1      2025-09-25 [?] RSPM
 P ggplot2      * 4.0.3.9000 2026-10-26 [?] Github (tidyverse/ggplot2@b638d65)
 P ggrepel      * 0.9.8      2026-03-17 [?] RSPM
 P ggunchull    * 1.0.1      2026-10-10 [?] Github (sajuukLyu/ggunchull@282c549)
 P lubridate    * 1.9.5      2026-02-04 [?] RSPM
   mascarade    * 0.4.3      2026-07-30 [1] RSPM
 P patchwork    * 1.3.2      2025-08-25 [?] RSPM
 P plotly       * 4.12.1     2026-07-22 [?] RSPM
 P purrr        * 1.2.2      2026-04-10 [?] RSPM
 P RColorBrewer * 1.1-3      2022-04-03 [?] RSPM
 P readr        * 2.2.0      2026-02-19 [?] RSPM
 P scRNAtoolVis * 0.1.0      2026-10-10 [?] Github (junjunlab/scRNAtoolVis@c99eaa8)
 P Seurat       * 5.6.0      2026-10-03 [?] RSPM
 P SeuratObject * 5.4.0      2026-04-11 [?] RSPM
 P sp           * 2.2-4      2026-10-11 [?] RSPM
 P stringr      * 1.6.0      2025-11-04 [?] RSPM
 P tibble       * 3.3.1      2026-01-11 [?] RSPM
 P tidydr       * 0.0.7      2026-10-09 [?] RSPM
 P tidyr        * 1.3.2      2025-12-19 [?] RSPM
 P tidyverse    * 2.0.0      2023-02-22 [?] RSPM

 [1] /home/runner/.cache/R/renv/library/Bizard-5140bdc6/linux-ubuntu-noble/R-4.6/x86_64-pc-linux-gnu
 [2] /home/runner/work/_temp/Library
 [3] /opt/R/4.6.1/lib/R/library

 * ── Packages attached to the search path.
 P ── Loaded and on-disk path mismatch.

──────────────────────────────────────────────────────────────────────────────

Data Preparation

Simulated PBMC-style Seurat object

We simulate 3,360 cells across 8 cell types with unequal population sizes (200–660 cells each) and a marker-gene block structure, 6 sequencing samples mapped to 6 condition groups, and 3 tissue sites. Cell barcodes carry their sample name (0001-1_Sample_3), the way merged Seurat objects name cells. The same counts → normalization → PCA → UMAP/t-SNE chain you would run on a real object is executed so the embedding looks realistic.

set.seed(2026)

type_sizes <- c("CD4 T cell" = 500, "CD8 T cell" = 460, "NK cell" = 300,
                "B cell" = 420, "Monocyte" = 460, "DC" = 200,
                "Fibroblast" = 360, "Epithelial" = 660)
cell_types <- names(type_sizes)
n_type <- length(cell_types)
n_cell <- sum(type_sizes)
n_gene <- 1200
markers_per <- 40

gene_names <- paste0("Gene", 1:n_gene)
counts <- matrix(rnbinom(n_cell * n_gene, size = 4, prob = 0.25), n_gene, n_cell)
counts <- matrix(as.integer(pmin(counts, 30)), n_gene, n_cell)

# Add a marker block for each cell type
celltype <- factor(rep(cell_types, times = type_sizes), levels = cell_types)
for (i in seq_len(n_type)) {
  block <- ((i - 1) * markers_per + 1):(i * markers_per)
  idx_i <- which(celltype == cell_types[i])
  boost <- rnbinom(length(block) * length(idx_i), size = 8, prob = 0.2)
  counts[block, idx_i] <- pmin(counts[block, idx_i] + as.integer(boost), 120)
}
rownames(counts) <- gene_names

# Sample-level metadata: 6 samples map to 6 condition groups
groups <- c("Control", "4hours", "12hours", "2days", "14days", "6weeks")
site_lv <- c("Tumor", "Normal", "Margin")
orig.ident_raw <- sample(paste0("Sample_", 1:6), n_cell, TRUE)
lut <- setNames(rep(groups, length.out = 6), sort(unique(orig.ident_raw)))
Group <- factor(lut[orig.ident_raw], levels = groups)
site <- factor(sample(site_lv, n_cell, TRUE), levels = site_lv)

# Cell barcodes carry the sample name, as in merged Seurat objects
colnames(counts) <- paste0(sprintf("%04d", seq_len(n_cell)), "-1_", orig.ident_raw)

meta <- data.frame(
  celltype = celltype, Group = Group, orig.ident_raw = orig.ident_raw, site = site,
  row.names = colnames(counts)
)

sce <- CreateSeuratObject(counts = counts, meta.data = meta)
sce <- NormalizeData(sce, verbose = FALSE)
sce <- FindVariableFeatures(sce, nfeatures = 800, verbose = FALSE)
sce <- ScaleData(sce, verbose = FALSE)
sce <- RunPCA(sce, npcs = 20, verbose = FALSE)
sce <- RunUMAP(sce, dims = 1:15, verbose = FALSE)
sce <- RunTSNE(sce, dims = 1:15, verbose = FALSE)
Idents(sce) <- "celltype"

Journal practice 1: order clusters by cell count

Figure aesthetics break when every panel orders cell types differently. Sort the identity factor by decreasing cell count once, and every downstream figure inherits the same order (a step from the source article’s “data preprocessing” section). With the unequal populations simulated above, the reorder is visible: Epithelial (660 cells) comes first and DC (200 cells) last.

# Order cell types from largest to smallest population
celltype_counts <- table(sce$celltype)
ord <- names(sort(celltype_counts, decreasing = TRUE))
sce$celltype <- factor(sce$celltype, levels = ord)
Idents(sce) <- "celltype"
celltype_counts

CD4 T cell CD8 T cell    NK cell     B cell   Monocyte         DC Fibroblast 
       500        460        300        420        460        200        360 
Epithelial 
       660 

Journal practice 2: fix SeuratProject sample labels

Cells that lost their identity during multi-dataset merging carry the default SeuratProject origin, which scrambles sample grouping. Recover the real sample name from the cell barcode with a regular expression, as in the source article:

# Inject the pathology for demonstration
idx_bad <- sample(seq_len(ncol(sce)), 60)
sce$orig.ident_raw[idx_bad] <- "SeuratProject"

idx_to_fix <- which(sce$orig.ident_raw == "SeuratProject")
if (length(idx_to_fix) > 0) {
  message(paste("Found", length(idx_to_fix), "cells with a SeuratProject label"))
  cell_names <- colnames(sce)[idx_to_fix]
  new_idents <- str_extract(cell_names, "[^_]+_[^_]+$")
  if (any(is.na(new_idents))) {
    new_idents[is.na(new_idents)] <- cell_names[is.na(new_idents)]
  }
  sce$orig.ident_raw[idx_to_fix] <- new_idents
}
Found 60 cells with a SeuratProject label
table(sce$orig.ident_raw)

Sample_1 Sample_2 Sample_3 Sample_4 Sample_5 Sample_6 
     586      546      529      546      577      576 

A shared palette

selCols <- colorRampPalette(brewer.pal(n = 10, name = "Paired"))(8)
names(selCols) <- levels(sce$celltype)

Visualization

Baseline panels

A labelled UMAP by cell type, the embedding coloured by condition group and by original sample (batch-structure checks), the same embedding split by tissue site, and a patchwork composite combining cell-type and t-SNE views with journal letter tags (tag_levels = "A"). UMAP emphasizes local similarity between related populations; t-SNE separates distinct populations, so the two together make a stronger quality check.

p_base <- DimPlot(sce, reduction = "umap", group.by = "celltype",
                  label = TRUE, label.size = 4, repel = TRUE, cols = selCols) +
  theme_dr() +
  theme(panel.grid = element_blank(),
        legend.text = element_text(size = 10, color = "black")) +
  guides(color = guide_legend(title = "", override.aes = list(size = 3, alpha = 1)))
p_split <- DimPlot(sce, reduction = "umap", split.by = "site", group.by = "celltype",
                   label = FALSE, cols = selCols, ncol = 3) +
  theme(panel.grid = element_blank(), legend.position = "none")
p_base + p_split + plot_layout(widths = c(1, 1.4))
Figure 1: Labelled UMAP coloured by cell type, split by tissue site
# A large palette keeps many samples distinguishable
sample_cols <- colorRampPalette(brewer.pal(n = 12, name = "Set3"))(length(unique(sce$orig.ident_raw)))
p_group <- DimPlot(sce, reduction = "umap", group.by = "Group",
                   label = TRUE, label.size = 4, repel = TRUE) +
  theme(panel.grid = element_blank())
p_sample <- DimPlot(sce, reduction = "umap", group.by = "orig.ident_raw",
                    label = FALSE, cols = sample_cols) +
  theme(panel.grid = element_blank())
p_group | p_sample
Figure 2: UMAP coloured by condition group (left) and by original sample (right)
p1_umap <- DimPlot(sce, reduction = "umap", group.by = "celltype",
                   label = TRUE, label.size = 4, repel = TRUE, cols = selCols) +
  theme(panel.grid = element_blank())
p3_tsne <- DimPlot(sce, reduction = "tsne", group.by = "celltype",
                   label = TRUE, label.size = 4, repel = TRUE, cols = selCols) +
  theme(panel.grid = element_blank())
combined_plot <- p1_umap + p3_tsne +
  plot_annotation(tag_levels = "A") &
  theme(text = element_text(family = "Arial"),
        plot.tag = element_text(family = "Arial", size = 16, face = "bold"))
combined_plot
Figure 3: Patchwork composite: UMAP by cell type (A) and t-SNE (B) with journal letter tags

The composite is exported the standard way: ggsave(combined_plot, filename = "combined_umap.pdf", width = 16, height = 9).

Scheme 1: tidydr arrow axes — universal SCI style

theme_dr() replaces the grey grid with L-shaped arrow axes, strips the title, and keeps a single-column legend. High fault tolerance and suitable for main text figures in most SCI journals.

p_tidydr <- DimPlot(sce, reduction = "umap", group.by = "celltype",
                    label = TRUE, repel = TRUE, cols = selCols) +
  theme_dr() +
  theme(panel.grid = element_blank(),
        legend.text = element_text(size = 12, face = "plain", color = "black"),
        plot.title = element_blank()) +
  guides(color = guide_legend(title = "", override.aes = list(size = 4, alpha = 1), ncol = 1))
p_tidydr
Figure 4: UMAP with tidydr arrow axes

Scheme 2: scRNAtoolVis cloud outlines — CNS sub-journal style

clusterCornerAxes() draws corner axes plus translucent circular halos (addCircle = TRUE) around each cluster. Key parameters: cicAlpha (halo transparency) and cicDelta (how far the halo extends beyond the cell cloud, in data units). Note that nbin only takes effect in the legacy circle mode (addCircle.legacy = TRUE); with the current default it is ignored. Loosely packed populations yield softer, more cloud-like halos — decrease cicDelta for tighter outlines.

p_corneraxes <- clusterCornerAxes(
  object = sce,
  reduction = "umap",
  clusterCol = "celltype",
  pSize = 0.3,
  arrowType = "open",
  lineTextcol = "grey50",
  cornerTextSize = 3.5,
  keySize = 1.5,
  show.legend = FALSE,
  cellLabel = TRUE,
  cellLabelSize = 5,
  noSplit = TRUE,
  addCircle = TRUE,
  cicAlpha = 0.1,
  cicDelta = 0.5,
  nbin = 200
) + scale_color_manual(values = selCols) +
  scale_fill_manual(values = selCols)
Warning: `aes_string()` was deprecated in ggplot2 3.0.0.
ℹ Please use tidy evaluation idioms with `aes()`.
ℹ See also `vignette("ggplot2-in-packages")` for more information.
ℹ The deprecated feature was likely used in the scRNAtoolVis package.
  Please report the issue to the authors.
Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
ℹ Please use `linewidth` instead.
ℹ The deprecated feature was likely used in the scRNAtoolVis package.
  Please report the issue to the authors.
p_corneraxes
Figure 5: UMAP with scRNAtoolVis corner axes and cloud outlines

Scheme 3: ggunchull dashed hulls — polished review style

stat_unchull() wraps each population in a smooth, non-convex dashed hull. Cell-type labels are placed at shifted medians with geom_text_repel(), keeping the plot tidy without a legend.

meta <- cbind(sce@meta.data, sce@reductions$umap@cell.embeddings)
main_type_med <- meta %>%
  group_by(celltype) %>%
  summarise(umap_1 = median(umap_1) - 1, umap_2 = median(umap_2) - 1, .groups = "drop")

p_unchull <- ggplot(meta, aes(x = umap_1, y = umap_2)) +
  stat_unchull(aes(fill = celltype, color = celltype),
               alpha = 0.05, size = 1, lty = 2, delta = 0.25, show.legend = FALSE) +
  geom_point(aes(color = celltype), size = 0.2, show.legend = FALSE) +
  scale_color_manual(values = selCols) +
  scale_fill_manual(values = selCols) +
  geom_text_repel(data = main_type_med, aes(label = celltype),
                  fontface = "bold", color = "black", size = 4,
                  show.legend = FALSE, seed = 42) +
  coord_equal() +
  theme_classic() +
  theme(aspect.ratio = 1,
        panel.border = element_rect(color = "black", fill = NA, linewidth = 0.8),
        axis.line = element_blank(),
        axis.ticks = element_line(color = "black"),
        axis.title = element_text(size = 12))
p_unchull
Figure 6: UMAP with ggunchull dashed hulls and centred labels

Scheme 4: mascarade fine masks — high-density atlas style

generateMask() turns per-cluster kernel densities into contour masks that follow the cell distribution. The mask table adds a group column, so a single geom_path(group = group) layer draws every outline. Cell-type labels sit on the medians of each population.

df <- FetchData(object = sce, vars = c("umap_1", "umap_2", "celltype"))
celltype_counts <- table(df$celltype)
valid_celltypes <- names(celltype_counts)[celltype_counts >= 2]
df_filtered <- df[df$celltype %in% valid_celltypes, ]
rownames(df_filtered) <- NULL

# Generate the fine contour masks
maskTable <- generateMask(
  dims = df_filtered[, 1:2],
  clusters = df_filtered$celltype
)

p <- ggplot(df, aes(x = umap_1, y = umap_2)) +
  geom_point(aes(color = celltype), size = 0.05) +
  geom_path(data = maskTable, aes(group = group), linewidth = 0.3, linetype = 2) +
  coord_fixed(ratio = 1) +
  theme_classic() +
  scale_color_manual(values = selCols) +
  theme_dr(xlength = 0.22, ylength = 0.22,
           arrow = grid::arrow(length = unit(0.15, "inches"), type = "closed")) +
  theme(panel.grid = element_blank())

centroids <- df %>%
  group_by(celltype) %>%
  summarise(centroid_x = median(umap_1, na.rm = TRUE),
            centroid_y = median(umap_2, na.rm = TRUE), .groups = "drop")

p_mascarade <- p + geom_text(data = centroids,
                             aes(label = celltype, x = centroid_x, y = centroid_y),
                             size = 3.5, vjust = -0.2, hjust = 0.5,
                             color = "black", fontface = "bold")
p_mascarade
Figure 7: UMAP with mascarade fine contours and centroid labels
Note

generateMask() changed its interface in mascarade 0.2.0: minDensity/kernel/type were deprecated in favour of automatic bandwidth selection (smoothSigma = NA by default), and the cluster argument is now clusters — older examples passing cluster = only worked through R’s partial argument matching. The chunk above uses the current API.

Bonus: 3D density landscape

The viral “three-dimensional kernel-density landscape” builds one density terrain per cell type over the shared UMAP plane — a 3-D mountain scene where height is kernel density. Each terrain is a plotly surface with a shared white→blue→red colour scale, trimmed to the region where it is non-negligible; labels hover above the summits. It is an interactive widget — drag to rotate; for static export install the Python kaleido package and use plotly::save_image().

n_grid <- 80
lims <- c(range(df$umap_1), range(df$umap_2))
kd_list <- lapply(split(df, df$celltype), function(d) {
  MASS::kde2d(d$umap_1, d$umap_2, n = n_grid, lims = lims)
})
zmax <- max(sapply(kd_list, function(k) max(k$z))) * 1.05

# One shared white -> blue -> red density scale across all terrains
terrain <- list(c(0, "#f7f6f1"), c(0.35, "#9fc4dd"), c(0.65, "#33506e"), c(1, "#b2182b"))
fig <- plot_ly()
for (ct in levels(df$celltype)) {
  k <- kd_list[[ct]]
  # Trim the grid to the region where this terrain is non-negligible
  keep_x <- which(apply(k$z, 1, max) > zmax * 0.01)
  keep_y <- which(apply(k$z, 2, max) > zmax * 0.01)
  pad <- 3
  keep_x <- seq(max(1, min(keep_x) - pad), min(nrow(k$z), max(keep_x) + pad))
  keep_y <- seq(max(1, min(keep_y) - pad), min(ncol(k$z), max(keep_y) + pad))
  fig <- add_surface(fig, x = k$x[keep_x], y = k$y[keep_y],
                     z = k$z[keep_x, keep_y, drop = FALSE],
                     colorscale = terrain, cmin = 0, cmax = zmax,
                     showscale = (ct == levels(df$celltype)[1]),
                     colorbar = list(title = list(text = "Density"), x = 1.02, len = 0.7),
                     opacity = 0.95, name = ct,
                     hovertemplate = paste0(ct, "<br>UMAP_1: %{x:.2f}<br>UMAP_2: %{y:.2f}<br>Density: %{z:.4f}<extra></extra>"))
}

# Cell-type labels hovering above each summit
centroids <- df %>%
  group_by(celltype) %>%
  summarise(x = median(umap_1), y = median(umap_2), .groups = "drop")
kd_at <- function(ct, xx, yy) {
  k <- kd_list[[ct]]
  k$z[cbind(findInterval(xx, k$x), findInterval(yy, k$y))]
}
centroids$z <- mapply(kd_at, centroids$celltype, centroids$x, centroids$y) + zmax * 0.12
fig <- add_text(fig, data = centroids, x = ~x, y = ~y, z = ~z, text = ~celltype,
                textfont = list(color = "black", size = 12), showlegend = FALSE,
                hoverinfo = "skip")

no_bg <- list(showbackground = FALSE, zeroline = FALSE, showline = FALSE)
fig <- fig %>% layout(
  scene = list(xaxis = c(list(title = "UMAP_1"), no_bg),
               yaxis = c(list(title = "UMAP_2"), no_bg),
               zaxis = c(list(title = "Density"), no_bg))
)
fig

Choosing among the schemes

Scheme Style Best for
tidydr Arrow axes, no grid Main-text figures in most SCI journals
scRNAtoolVis Corner axes + cloud halos High-impact original-research figures
ggunchull Dashed hulls + bold labels Review articles and polished main figures
mascarade Density-fitted fine masks High-density, large single-cell atlases

References

[1] Hao Y, Hao S, Andersen-Nissen E, et al. Integrated analysis of multimodal single-cell data. Cell. 2021;184(13):3573–3587. https://doi.org/10.1016/j.cell.2021.04.048 (Seurat)

[2] Yu G. tidydr: an R package to create dimension reduction plots with styled axes. https://cran.r-project.org/package=tidydr

[3] Zhang J. scRNAtoolVis: Making single-cell RNA-seq visualization easier and better. https://github.com/junjunlab/scRNAtoolVis

[4] Lyu Y. ggunchull: A ggplot extension for drawing smooth non-convex circles around a set of points. https://github.com/sajuukLyu/ggunchull

[5] Sergushichev A. mascarade: Generating Cluster Masks for Single-Cell Dimensional Reduction Plots. https://github.com/alserglab/mascarade

[6] 罗小黑. AI分析单细胞+空转:umap大合集(三). WeChat article. https://mp.weixin.qq.com/s/ItyeGIJRhSFEaibw9KUbgw