
做单细胞分析的同学应该都有这种经历注释完细胞类型第一步就是看各个样本里细胞组成有什么差异。最直观的方式就是堆叠柱状图一根柱子一个样本不同颜色代表不同细胞类型一眼能看出谁多谁少。但样本一多、组别一多问题就来了——你只看到每根柱子颜色比例在变却说不清“哪一种细胞在组间是升高还是降低”。这时候在堆叠柱顶部加一条连线、再标上点趋势立刻清清楚楚。今天要分享的就是在R语言里做这种“带连线的堆叠柱状图”——既保留堆叠图的整体构成信息又通过连线和点把单类细胞的比例变化趋势叠加在同一张图上。文章会把从Seurat对象提取数据、清洗成绘图格式、ggplot2绘图、最后调细节的全过程拆开讲每一步都给了能直接跑的代码。适合已经做完单细胞注释、正在为数据可视化发愁的同学也适合想把手里的堆叠柱状图做得更专业的R语言初学者。全程用一份模拟数据演示不用真实数据也能复现代码逻辑你只需要把自己的分组名和细胞类型列替换进去就行。1. 堆叠柱状图的进阶需求从“看构成”到“看趋势”1.1 带连线的堆叠柱状图解决的是什么问题普通的堆叠柱状图y轴是百分比或细胞数x轴是样本或分组柱子内部按细胞类型分色填充。这个图最大的优点是直观——一眼看到每个样本的细胞组成轮廓。但它也有个明显的短板当你想对比“某种细胞类型在不同样本中的变化趋势”时视觉上非常费力。比如你有健康组、疾病组、治疗组三组样本每组测了5个个体想重点看CD8 T细胞的比例变化。堆叠图里你会不自觉地盯着每一根柱子里的那一小段颜色比较高度。颜色深浅相近、柱子比例接近的时候肉眼根本分不出差别。解决思路很简单在堆叠柱状图的基础上把某一类细胞的百分比单独画成折线或点叠加到同一张图上。点的高低代表该类细胞的占比连线把不同样本的点串起来趋势一眼就能看出来。更进阶一点不只在图上画一条线而是给主要细胞类型都可以标上点甚至每条线一种颜色和堆叠段的颜色保持一致。这样构成和趋势信息同时呈现图的信息密度高了一倍。1.2 为什么不用分面图或单独画折线有人会说想看趋势那我单独画一张折线图不就行了确实可以但有一个本质区别单独画折线图时你看到的是“这一类细胞的变化”但你丧失了“这一类在整体里占多大比例”的上下文。打个比方CD8 T细胞从10%涨到20%如果只看折线图你觉得涨幅很大但如果你同时看到NK细胞从40%降到30%你就会意识到这可能不是CD8 T细胞在增殖而是NK细胞在减少CD8 T的占比是被动上升的。堆叠柱状图保留了这个上下文信息整根柱子高度是100%每个色段就是该类型在整体中的占比。叠加的连线让你同时抓取“整体构成”和“单类趋势”两个维度。这就是这种组合图的核心价值。另一种替代方案是用分面图每个细胞类型一个小图。这种方式也不是不行但分面会割裂样本间的横向对比而且排版长、占版面大。对于论文汇报、分组对比、审稿人快速抓重点这些场景一张组合图远比四五个分面图好使。1.3 适用场景与不适用场景适用场景非常明确样本分组少2~6组每个样本有独立柱子连线才有对比意义需要同时展示整体组成和某一类细胞的变化趋势图表准备用于组会汇报、论文插图或项目交付想提高信息表达效率。不适用的情况也要说清楚样本数特别多比如几十个样本平铺在x轴上连线会变成一团乱麻这时候更适合用热图或分组箱线图细胞类型特别多超过10类堆叠图本身已经够挤再加线条会视觉爆炸建议先合并稀有类型如果只是想看一个细胞类型在两组之间的差异那直接画一个箱线图加显著性标记更清晰。所以这种带连线的堆叠柱状图最适合的中等规模数据5~15个样本、5~8种细胞类型、2~4个分组。这个区间内图既能容纳足够信息又不会因为过载而失去可读性。我之前在单细胞数据里最常用的场景就是“不同处理条件下的细胞组成变化”——8个样本、7类细胞、3个处理组一张图直接支撑了整页结果。2. 从Seurat对象到绘图数据这步做不对后面全白搭2.1 绘图数据长什么样把宽表转成长表在R里画ggplot2图数据格式至关重要。ggplot2底层是“长表思维”——每一个观测值是一行每一列是一个变量。但单细胞分析里我们统计出来的比例数据天然是“宽表”结构每一行是一个样本每一列是一种细胞类型表格里是百分比数值。举个例子从Seurat里统计出来的原始比例表是这样的sampleB_cellCD8_TCD4_TNKMonocytectrl_115.222.130.518.313.9ctrl_216.821.329.817.914.2treat_110.535.622.415.216.3treat_29.838.220.914.516.6而ggplot2画堆叠柱状图需要的数据格式是这样的samplecell_typepercentagectrl_1B_cell15.2ctrl_1CD8_T22.1ctrl_1CD4_T30.5.........这种从“每列是一种类型”到“每行是一个观测”的转换就是宽表转长表pivot longer。这一步很多人会大意直接用宽表丢给ggplot2结果报错或者画出一堆乱码。核心原因就是没理解ggplot2的aes(x..., y..., fill...)是“列名映射”宽表里每一种细胞类型是一个列名你没法用一个fill参数同时映射5个列。2.2 从Seurat对象提取细胞类型和样本信息如果你的数据已经跑完标准Seurat流程细胞类型注释存放在meta.data里比如列名叫cell_type样本名在orig.ident列。提取比例表的标准做法是交叉表统计。# 假设你的Seurat对象叫 sce # cell_type 是注释好的细胞类型列orig.ident 是样本列 library(dplyr) library(tidyr) # 方法一直接用table计算频数 freq_table - as.data.frame(table(sce$orig.ident, sce$cell_type)) colnames(freq_table) - c(sample, cell_type, count) # 方法二用dplyr流程更灵活 freq_table - scemeta.data %% dplyr::count(orig.ident, cell_type) %% dplyr::rename(sample orig.ident)这两种方法得到的freq_table都是长表结构。如果你后续还需要分组信息比如每个样本属于处理组还是对照组建议再做一步合并把样本对应的分组信息join进来。# 假设你有一个样本分组信息表 group_info列名是 sample 和 group group_info - data.frame( sample c(ctrl_1, ctrl_2, treat_1, treat_2), group c(ctrl, ctrl, treat, treat) ) freq_table - freq_table %% left_join(group_info, by sample)这一步非常关键。后续画图如果要在x轴标签或颜色上体现分组没有group列是寸步难行的。我就是因为前期没加这一列后期想按组给柱子上色又回头重新导数据白白折腾了半小时。2.3 计算比例并转换成长表频数表只能看到细胞数量的绝对值但堆叠柱状图一般展示百分比因为样本之间的细胞总数往往差异很大——测序深度不同、细胞捕获数不同直接比绝对数量没有意义。计算比例的标准方法是用group_by加mutate。freq_table - freq_table %% group_by(sample) %% mutate(pct count / sum(count) * 100) %% ungroup()这里有个细节容易出错group_by(sample)是对每个样本单独算比例保证同一个样本的所有细胞类型比例加起来是100%。如果你不小心group_by了别的列或者忘了ungroup后面画图时比例就会错乱。我建议每次算完比例都验证一下freq_table %% group_by(sample) %% summarise(total_pct sum(pct)) %% filter(abs(total_pct - 100) 0.01)如果有样本的total_pct不是100允许浮点数误差说明group_by出了问题回到上一步检查。这一步验证只花10秒钟但能避免画出一张让人怀疑人生的图。其实如果你一开始就是用dplyr::count得到长表那么freq_table已经是长表格式了不需要再做宽表转长表。但如果是用table()函数或者从Excel读入宽表数据就必须要pivot_longer。# 如果已有宽表 wide_data列名是 sample, B_cell, CD8_T, CD4_T, NK, Monocyte long_data - wide_data %% pivot_longer(cols -sample, names_to cell_type, values_to pct)cols -sample表示除了sample列其他列都转成长表names_to指定原来列名存到哪一列values_to指定数值存到哪一列。这句话是ggplot2绘图数据准备的常用语法值得记牢。3. 核心绘图代码一个ggplot2就搞定堆叠柱加连线3.1 先画基础堆叠柱状图数据准备好之后绘图其实只有几步。先画一个最基础的堆叠柱状图把框架搭起来。library(ggplot2) library(RColorBrewer) # 假设 long_data 有 sample, cell_type, pct 三列 p - ggplot(long_data, aes(x sample, y pct, fill cell_type)) geom_bar(stat identity, width 0.6, color white, linewidth 0.3) theme_classic() labs(x NULL, y Percentage (%), fill Cell type) theme( axis.text.x element_text(angle 45, hjust 1), legend.position right ) p这里geom_bar用的是statidentity意思是直接把y值作为柱子高度而不是统计频数。fillcell_type决定了堆叠的颜色映射。colorwhite是给每段色块加白色描边让相邻颜色之间有清晰的界限对于颜色相近的细胞类型尤其重要。跑出基础图后你会发现信息其实已经到位了但缺了趋势线。这个趋势线不是用geom_line直接画在柱子上而是需要先单独准备一个“用于画线的数据框”。为什么要单独准备因为堆叠柱状图里每个样本有多段颜色而连线只需要每个样本一个点。你需要的数据结构是每个样本、每个你关心的细胞类型、一个百分比数值。如果想画多条线就是每个样本、多个细胞类型。3.2 准备连线数据只保留要画线的细胞类型我的建议是在一张图上连线的细胞类型不要超过3~4条否则线条重叠、颜色混杂反而看不清。实际操作中你最关心的往往是1~2种关键细胞类型比如治疗组的CD8 T和NK。line_data - long_data %% filter(cell_type %in% c(CD8_T, NK))这一步很简单但潜藏一个坑过滤后cell_type这个因子水平并不会自动删除因子水平里还保留着B_cell、CD4_T等没用到的类型。如果不处理后面画颜色映射时可能出问题。用droplevels()清理一下line_data$cell_type - droplevels(line_data$cell_type)3.3 在堆叠柱上叠加连线和点连线和点的核心绘图逻辑geom_line需要按组连线所以group必须映射成细胞类型否则ggplot2不知道哪些点连成一条线。p_line - p geom_line(data line_data, aes(x sample, y pct, group cell_type, color cell_type), linewidth 1) geom_point(data line_data, aes(x sample, y pct, color cell_type), size 2.5) scale_color_manual(values c(CD8_T #E64B35, NK #00A087), name Cell type (line))这里有个细节geom_line和geom_point的data都是line_data而不是原始的long_data。因为原始数据每个样本有多行多个细胞类型如果直接用它画线geom_line会把同一组内的所有点连起来但“组”是按group分的如果你在原始长表上画线就会把同一个样本的所有细胞类型按顺序连成一条乱七八糟的线。所以必须单独准备line_data每个样本每个要画线的细胞类型只有一行。另外注意colorcell_type这层映射会和fillcell_type这层映射在legend里产生两个图例一个是填充色的图例一个是线条颜色的图例。默认情况下图例会分开显示导致图例区变得很长。解决方法是合并图例后面会专门讲。3.4 完整代码一份可以直接跑通的版本把以上步骤整合起来基于模拟数据做一份完整可运行的代码。数据是构造的但结构完全对齐真实单细胞数据导出后的格式。# # 带连线的堆叠柱状图完整代码 # library(ggplot2) library(dplyr) library(tidyr) # 1. 构造模拟数据模拟从Seurat导出的长表比例数据 set.seed(42) samples - paste0(rep(c(ctrl, treat), each 4), _, 1:4) cell_types - c(B_cell, CD8_T, CD4_T, NK, Monocyte) long_data - expand.grid(sample samples, cell_type cell_types) %% mutate(pct round(runif(n(), 5, 40), 1)) # 让每个样本比例加起来约100% long_data - long_data %% group_by(sample) %% mutate(pct round(pct / sum(pct) * 100, 1)) %% ungroup() # 2. 指定细胞类型顺序这个顺序决定堆叠顺序和图例顺序 cell_type_order - c(B_cell, CD8_T, CD4_T, NK, Monocyte) long_data$cell_type - factor(long_data$cell_type, levels cell_type_order) # 3. 指定样本顺序 sample_order - c(ctrl_1, ctrl_2, ctrl_3, ctrl_4, treat_1, treat_2, treat_3, treat_4) long_data$sample - factor(long_data$sample, levels sample_order) # 4. 准备连线数据只画CD8_T和NK两条线 line_data - long_data %% filter(cell_type %in% c(CD8_T, NK)) # 5. 手动指定颜色向量颜色顺序要和cell_type_order对应 fill_colors - c( B_cell #4DBBD5, CD8_T #E64B35, CD4_T #00A087, NK #3C5488, Monocyte #F39B7F ) line_colors - c( CD8_T #E64B35, NK #3C5488 ) # 6. 绘图 p_final - ggplot(long_data, aes(x sample, y pct, fill cell_type)) geom_bar(stat identity, width 0.6, color white, linewidth 0.3) geom_line(data line_data, aes(x sample, y pct, group cell_type, color cell_type), linewidth 1) geom_point(data line_data, aes(x sample, y pct, color cell_type), size 2.5) scale_fill_manual(values fill_colors, name Cell type) scale_color_manual(values line_colors, name Cell type) theme_classic(base_size 14) labs(x NULL, y Percentage (%)) theme( axis.text.x element_text(angle 45, hjust 1, size 10), legend.position right ) guides(fill guide_legend(order 1, override.aes list(color NA)), color guide_legend(order 2, override.aes list(shape 21, fill white))) print(p_final)这个版本已经能正常出图。最后一步的guides函数里用了override.aes来微调图例fill图例里把色块的颜色覆盖成NA避免色块周围出现奇怪的描边color图例里让线条图例的点变成空心圆。这些细节不处理图也能看但处理了之后更专业。4. 细节定成败排序、图例、颜色映射一次理清4.1 factor水平顺序决定一切很多人画堆叠柱状图时最头疼的一个问题我想让某种细胞类型堆在最下面为什么图里它跑到最上面去了问题的根源在factor水平顺序。ggplot2的geom_bar在堆叠时默认按照factor的水平顺序从下往上堆叠第一个level在最下面最后一个level在最上面。图例的顺序则是从上到下对应第一个level到最后一个level。也就是说如果你的cell_type列的factor水平是c(B_cell, CD8_T, CD4_T, NK, Monocyte)那么每根柱子里从下到上的顺序是B_cell → CD8_T → CD4_T → NK → Monocyte图例中从上到下的顺序也是B_cell → CD8_T → CD4_T → NK → Monocyte这两者是一致的不会出现图例和柱子顺序“打架”的问题。但如果你不设置factor直接读入数据R会按字母顺序排序factor水平这经常不符合你的预期。比如Monocyte会排第一图里就是Monocyte在底部而不是你想要的B_cell在底部。解决方式就是前面代码里写到的在绘图前用factor(..., levels ...)显式指定顺序。这一步必须做而且要放在绘图之前。4.2 堆叠顺序与图例顺序的一致性有时候你想让图例顺序和柱子堆叠顺序保持一致但有“不想改数据里factor顺序”的情况。比如你想让CD8_T堆在最下面对应的图例在最上面。这时不需要改数据可以在guides里控制图例顺序。p guides(fill guide_legend(order 1, reverse FALSE))如果想让图例顺序与堆叠顺序相反可以加reverse TRUE。实际工作中我很少用reverse因为默认一致已经是大多数人习惯的阅读顺序。还有个更隐蔽的坑当你用scale_fill_manual手动指定values时如果不写breaks参数ggplot2会按factor水平取颜色值。这意味着values里的名字和顺序只要和factor水平不完全一致就可能出现某种细胞类型被赋予错误的颜色。保险做法是让values里的名称和factor水平完全对应顺序可以不同但建议保持一致。我在代码里用的就是按顺序一一对应的写法肉眼看着最不容易出错。4.3 手动指定颜色让图例和线段颜色统一堆叠柱的fill颜色和连线的color颜色逻辑上是两个独立的映射。但视觉上需要统一CD8_T的色块和CD8_T的线条应该是同一种颜色否则读者会困惑“这条线代表的是什么”。这就是为什么要分fill_colors和line_colors两个向量但保证相同细胞类型的颜色值完全一致。你可以把两个向量合并成一个总颜色表然后分别取子集更不容易写错all_colors - c( B_cell #4DBBD5, CD8_T #E64B35, CD4_T #00A087, NK #3C5488, Monocyte #F39B7F ) fill_colors - all_colors[levels(long_data$cell_type)] line_colors - all_colors[c(CD8_T, NK)]这样两个向量的颜色值天然一致不会出现色块是红色、线条却是蓝色的尴尬。配色方面建议使用NPG配色、lancet配色或者自定义的一组高对比度颜色避免用ggplot2默认的淡色系因为淡色在堆叠柱里区分度低打印出来更是一团灰。4.4 坐标轴标签和整体美化样本多的时候x轴标签会挤成一团。常见处理方式有两种旋转45度或90度。旋转45度适合标签不长的情况旋转90度适合标签较长的情况。theme(axis.text.x element_text(angle 45, hjust 1))hjust1是右对齐这样标签靠近坐标轴的部分正好对齐视觉上比较整齐。如果你有分组概念想在x轴上区分对照组和处理组可以在x轴标签下方加一条分组色带。实现方式是在图上用annotate加矩形或者用ggh4x包的geom_strip但那样会引入额外依赖。简单做法是在样本名上加前缀并旋转标签比如“ctrl_1”改成“Ctrl 1”用换行或颜色表示分组。我自己常用的是在样本名下方加一个半透明矩形色块配合scale_x_discrete的breaks和labels手动控制显示文本。这个方法稍复杂但做出来的图在汇报时非常加分。4.5 百分比数值标签加还是不加堆叠柱状图常被问到“能不能在每个色块上标出百分比”。技术上是可行的但有一个度的问题。如果细胞类型少4~5类、样本少4~6个给每个色块标百分比是清晰的。如果类型多、样本多标上数字反而让图变脏。如果确实要标注意不能用y直接映射因为堆叠图的坐标是累积式的。每个色块的y应该用“该类型的比例加上前面所有类型的比例之和”来计算。做法是在数据里加上一列poslong_data - long_data %% group_by(sample) %% mutate(pos cumsum(pct) - 0.5 * pct) %% ungroup()pos就是每个色块中心点的y坐标。然后用geom_text加标签geom_text(aes(y pos, label sprintf(%.1f, pct)), size 3, color white)白色文字在深色块上可读性高但在浅色块上可能看不清。可以选择用color black配白色描边或者根据色块深浅动态判断文字颜色。动态判断的办法是计算色块亮度但代码量会多不少一般我用一个简单办法文字统一用白字但浅色细胞类型的fill改成深色系的变体。这也是为什么我推荐用NPG配色而不是默认淡色系的原因——默认色的亮色块上白字完全没法看。5. 常见问题与调试心得5.1 geom_line连出来的线是乱的这是最常遇到的问题。表现是每根柱子上方出现一条横七竖八的折线把同一个样本内的所有细胞类型按照某种乱七八糟的顺序连在一起。原因几乎都是data用错了。画线时必须使用单独准备的line_data每个样本每个要画线的细胞类型只有一行。如果你直接把long_data传给geom_line同一group下同一个sample有多行ggplot2会按数据顺序把这几行依次连起来画出的线当然乱。排查方法用View(line_data)确认每个sample和cell_type的组合是不是只有一行检查aes里的group参数是否指定了cell_type检查数据是不是长表每个样本多行、每行一个细胞类型如果是宽表geom_line会报错或画出完全错误的结果。5.2 图例是分裂的两个图例怎么合并我前面提到fill和color是两层独立映射默认会生成两个图例哪怕name都写成了Cell type。但如果两者类别不完全一致fill有5类color只有2类即使name相同也无法自动合并因为图例的“键”数量不同。解决方案有两种让color映射的类别也包含5类但颜色设成NA线条不画但图例键保留这操作起来比较绕接受两个图例的做法只在视觉上通过override.aes让它们看起来统一。我上面完整代码里用的就是这种方案fill图例显示色块color图例显示带颜色的空心点加线条。两个图例并排放在右侧读者能自然理解色块代表堆叠部分、点线代表趋势。如果你实在想要一个图例还有一个思路把堆叠柱的fill映射和连线的color映射合并成同一层比如用ggnewscale包。library(ggnewscale) p - ggplot(long_data, aes(x sample, y pct)) geom_bar(aes(fill cell_type), stat identity) scale_fill_manual(values fill_colors, name Cell type) new_scale_fill() geom_line(data line_data, aes(group cell_type, color cell_type)) geom_point(data line_data, aes(color cell_type)) scale_color_manual(values line_colors, name Cell type)但两个图例依然会显示ggnewscale并不能直接合并图例只是隔离不同层的fill映射防止冲突。真正想完全合并成一个图例需要把线条数据也参与fillcolor双重映射这在有些场景下反而增加理解成本。我在实际项目中通常保留两个图例通过名字和颜色一致性让读者自然关联汇报时口头说明一下效果很稳定。5.3 堆叠顺序对了但图例顺序不对怎么办我前面说过默认情况下堆叠顺序和图例顺序都跟factor水平一致。如果你发现堆叠顺序对但图例顺序不对大概率是你在某个地方用了guides(reverse TRUE)或改变了图例的breaks顺序。排查方式去掉guides回到默认状态确认基本顺序在scale_fill_manual里手动指定breaks顺序写成你希望图例显示的顺序但注意breaks必须和levels内容一致只是顺序可以不同如果顺序还是乱直接用limits参数强制图例顺序scale_fill_manual(values fill_colors, breaks c(B_cell, CD8_T, CD4_T, NK, Monocyte))5.4 连线的点和柱子的位置没对齐正常情况geom_line和geom_point的x轴和geom_bar的x轴是同一个离散型变量的因子水平位置会自动对齐。如果出现点和柱子中心错位多半是x轴映射的因子水平不一致。比如geom_bar的x是sample而geom_line的x写成了group或者两个数据框中sample的factor水平不一致。解决办法确保line_data里的sample列和long_data里的sample列是同一个factor用levels函数检查并统一。绘图前把两个数据的sample统一成相同的factorline_data$sample - factor(line_data$sample, levels levels(long_data$sample))这一句简单但能解决很多莫名其妙的对齐问题。5.5 百分比标签重叠样本多、细胞类型多时色块上标的百分比数字会互相重叠。解决办法只给比例大于5%的类型加标签字体调小色块太窄时干脆不显示。实现方式是在geom_text的data里先filter。我通常是创建一个单独的数据框用于文字标签在里面过滤掉占比过小的类型。label_data - long_data %% filter(pct 5) %% group_by(sample) %% mutate(pos cumsum(pct) - 0.5 * pct) %% ungroup()这样只显示占比超过5%的标签图面清爽很多。5.6 连线太多导致图面混乱基因细胞类型多但只想展示几种关键线时建议把非关键类型的线条颜色全部调成浅灰色或者全部不画只保留关键类型。另一个技巧给连线添加透明度alpha让堆叠柱的色块更突出。颜色较深的线条配alpha0.8既保留趋势信息又不会喧宾夺主。我在最终版图里一般用alpha0.9视觉上线条依然清晰。6. 进阶扩展分组色带、显著性标记与拼图输出6.1 分组色带一眼看清样本属于哪个处理组前面提过如果样本按组排列x轴标签只显示样本名读者还得自己去记“ctrl_1、ctrl_2是实验组treat_1...是处理组”这在论文里是不可接受的。更专业的做法是在x轴底部加一条分组色带。实现思路是用geom_tile或者annotate在x轴下方画矩形色块并添加对应文本。这种方法稍微复杂一点但效果拔群。最简单的实现方式是直接用ggpubr或ggh4x包的辅助函数但如果你不想加依赖包可以用下面的方法# 构造分组标注数据 group_anno - data.frame( sample c(ctrl_1, ctrl_2, ctrl_3, ctrl_4, treat_1, treat_2, treat_3, treat_4), group c(rep(Ctrl, 4), rep(Treat, 4)) ) # 按sample顺序取每个组对应的x轴位置 group_anno$x_num - as.numeric(factor(group_anno$sample, levels sample_order)) p_anno - p_final annotate(rect, xmin c(0.5, 4.5), xmax c(4.5, 8.5), ymin -8, ymax -4, fill c(grey80, grey50), alpha 0.5) coord_cartesian(ylim c(-10, 100), clip off) annotate(text, x c(2.5, 6.5), y -6, label c(Ctrl, Treat), size 4)这种方式需要灵活调整ylim和文本位置第一次调的时候得试几次但调好后图面信息完整度直接上升一个档次。6.2 在连线上加显著性标记如果在不同分组之间做统计检验比如比较CD8_T在Ctrl组和Treat组的比例差异可以把p值标在连线的对应位置。实际操作中我一般用wilcox.test计算p值然后把星号加到geom_text中。# 提取CD8_T数据 cd8_data - long_data %% filter(cell_type CD8_T) %% left_join(group_anno, by sample) # 分组检验 pval - wilcox.test(pct ~ group, data cd8_data)$p.value pval_label - ifelse(pval 0.001, ***, ifelse(pval 0.01, **, ifelse(pval 0.05, *, ns)))如果你的样本量很小wilcox.test可能不稳定这时候可以直接在图上标注“n.s.”或者改用置换检验。显著性标记的位置一般放在两组样本对应x坐标的中间偏上区域用geom_text或annotate加一行文本。注意这是探索性分析的做法正式发表前最好补充多重检验校正的说明。6.3 多图拼合与输出单张图做完后经常需要把堆叠图、趋势图、差异热图拼成一张大图。R里最常用的拼图包是patchwork写法非常简洁。library(patchwork) p_left - p_final p_right - # 另一张图比如差异热图 combined - p_left p_right plot_layout(widths c(2, 1)) ggsave(combined_plot.pdf, combined, width 12, height 6, dpi 300)输出格式上单细胞论文插图我建议输出PDF矢量图加PNG预览图两份PDF用于投稿PNG用于组内共享。ggsave输出PDF时默认是矢量放大不模糊文字也不会失真。标题里说“附完整代码”为了方便直接复用我把最核心的绘制函数封装成一个自定义函数。这个函数接受长表数据、要画线的细胞类型、颜色向量返回一个ggplot对象。你在自己的项目里只要准备好长表数据一行就能出图。plot_stack_with_trend - function(long_data, line_types, fill_colors, line_colors, sample_order NULL, cell_type_order NULL) { # 设置factor水平 if (!is.null(sample_order)) { long_data$sample - factor(long_data$sample, levels sample_order) } if (!is.null(cell_type_order)) { long_data$cell_type - factor(long_data$cell_type, levels cell_type_order) } # 准备连线数据 line_data - long_data %% filter(cell_type %in% line_types) %% droplevels() # 画图 p - ggplot(long_data, aes(x sample, y pct, fill cell_type)) geom_bar(stat identity, width 0.6, color white, linewidth 0.3) geom_line(data line_data, aes(x sample, y pct, group cell_type, color cell_type), linewidth 1) geom_point(data line_data, aes(x sample, y pct, color cell_type), size 2.5) scale_fill_manual(values fill_colors, name Cell type) scale_color_manual(values line_colors, name Cell type) theme_classic(base_size 14) labs(x NULL, y Percentage (%)) theme(axis.text.x element_text(angle 45, hjust 1), legend.position right) return(p) }封装之后你只需要维护一份长表数据、两个颜色向量就能在不同样本组合里快速复用同一套可视化逻辑。我自己做单细胞项目时就是把这段函数存在一个单独的R脚本里所有能用到堆叠趋势图的报告都直接source调用省去了重复修改代码的时间。最后再分享一个我在实际项目里踩过的小坑当样本名是连续的编号比如ctrl_1到ctrl_5但某个样本因为质控被删除后只剩ctrl_1、ctrl_2、ctrl_4、ctrl_5时如果不显式指定sample_orderR会按字母顺序排列导致x轴顺序变成ctrl_1、ctrl_2、ctrl_4、ctrl_5正常但如果样本名还有ctrl_10、ctrl_11字母排序会把ctrl_10排到ctrl_2前面这就会出大问题。所以无论什么时候在绘图之前都用factor显式固定样本顺序这是堆叠柱状图里最值得养成的习惯。