高分Panel复现系列|单细胞数据下给你的富集通路接上蛋白/基因

单细胞语境下的"蛋白/基因 → 细胞状态"连接图。左侧展示差异蛋白或 marker,弧线表示这些蛋白归入不同免疫细胞激活状态,右侧气泡图再补充每个细胞状态的比例、显著性和命中数量。


图片来源

项目 内容
文章 Spatial multi-omics implicate the interaction between Tpex and B cells in tertiary lymphoid structures after neoadjuvant therapy
期刊/年份 Cancer Discovery,2026 OnlineFirst
图号 原文截图面板 F
DOI/链接 https://doi.org/10.1158/2159-8290.CD-25-0806

这篇文章聚焦新辅助治疗后三级淋巴结构中 Tpex 与 B 细胞互作。截图这个 panel 用蛋白/基因集合连接到不同免疫细胞激活状态,例如 T cell activation、B cell activation、Myeloid leukocyte activation 等。


图片解读

这类图可以拆成三层:

  1. 左侧蛋白/基因列表:每一行是一个蛋白或基因,旁边短色条可以标记来源、分组或蛋白类别。
  2. 中间弧线流向:每条弧线表示该蛋白被归入某个单细胞免疫状态。
  3. 右侧气泡图:横轴是 Protein ratio,颜色表示 -log10(p-value),点大小表示 Protein count。

这里的核心表达是:哪些蛋白集合更集中地指向 T 细胞、B 细胞、髓系细胞、肥大细胞或巨噬细胞相关激活状态。


输入数据

建议准备两张输入表。

1. 蛋白-细胞状态连接表:protein_cellstate_links.csv

列名 含义
protein_label 左侧展示的蛋白或基因名
gene_y 蛋白在纵轴上的排列位置
cell_state 蛋白对应的单细胞状态
cell_state_y_link 弧线连接到细胞状态色块的位置
tick_color 左侧短色条颜色

2. 细胞状态统计表:cellstate_enrichment.csv

列名 含义
cell_state 细胞状态名称
protein_count 命中蛋白数量
protein_ratio 命中蛋白比例
neg_log10_p -log10(p-value)
cell_state_color 细胞状态颜色
cell_state_y 细胞状态在纵轴上的中心位置
r 复制代码
library(tidyverse)
library(scales)

links <- read_csv("protein_cellstate_links.csv", show_col_types = FALSE)
cell_info <- read_csv("cellstate_enrichment.csv", show_col_types = FALSE)

需要示例数据的后台 添加小编 领取,调整好数据结构,以下代码可以直接复制粘贴运行。


第一步:固定细胞状态顺序和颜色

r 复制代码
cell_levels <- cell_info$cell_state

links <- links |>
  mutate(cell_state = factor(cell_state, levels = cell_levels))

cell_info <- cell_info |>
  mutate(cell_state = factor(cell_state, levels = cell_levels))

cell_cols <- setNames(cell_info$cell_state_color, cell_info$cell_state)

第二步:定义右侧气泡图区域

这里把气泡图作为整体画布中的一个小区域来画。这样更容易还原原图中"弧线图 + 右侧嵌入气泡图"的版式。

r 复制代码
panel_xmin <- 6.45
panel_xmax <- 8.35
panel_ymin <- 0
panel_ymax <- max(cell_info$cell_state_y + 4.8)

dot_df <- cell_info |>
  mutate(
    dot_x = rescale(
      protein_ratio,
      to = c(panel_xmin + 0.18, panel_xmax - 0.18),
      from = c(0, 0.18)
    ),
    dot_y = c(47, 35, 22, 11, 5),
    dot_col = col_numeric(
      palette = c("#1d2cff", "#7c169f", "#e23b2e", "#ff0000"),
      domain = c(5, 32)
    )(neg_log10_p),
    dot_size = rescale(protein_count, to = c(2.4, 10.5), from = c(5, 65))
  )

第三步:准备图例和细胞状态色块

r 复制代码
x_ticks <- tibble(
  ratio = c(0.05, 0.10),
  x = rescale(
    ratio,
    to = c(panel_xmin + 0.18, panel_xmax - 0.18),
    from = c(0, 0.18)
  )
)

color_legend <- tibble(
  x = seq(panel_xmin + 0.18, panel_xmin + 0.85, length.out = 80),
  y = 91,
  value = seq(5, 32, length.out = 80)
) |>
  mutate(
    fill_col = col_numeric(
      palette = c("#1d2cff", "#7c169f", "#e23b2e", "#ff0000"),
      domain = c(5, 32)
    )(value)
  )

size_legend <- tibble(
  x = c(panel_xmin + 0.18, panel_xmin + 0.70, panel_xmin + 1.26),
  y = 76,
  count = c(20, 40, 60),
  point_size = c(3.7, 5.6, 7.6)
)

cell_bar_df <- cell_info |>
  mutate(
    xmin = 5.98,
    xmax = 6.18,
    ymin = cell_state_y - 4.8,
    ymax = cell_state_y + 4.8
  )

第四步:绘制蛋白到细胞状态的弧线

r 复制代码
p <- ggplot() +
  geom_curve(
    data = links,
    aes(
      x = 0.64,
      y = gene_y,
      xend = 5.86,
      yend = cell_state_y_link,
      color = cell_state
    ),
    curvature = -0.34,
    linewidth = 0.24,
    alpha = 0.36
  ) +
  geom_segment(
    data = links,
    aes(x = 0.50, xend = 0.62, y = gene_y, yend = gene_y, color = tick_color),
    linewidth = 1.0,
    show.legend = FALSE
  ) +
  geom_text(
    data = links,
    aes(x = 0.47, y = gene_y, label = protein_label),
    hjust = 1,
    size = 1.34,
    color = "black"
  )

第五步:添加细胞状态色块和名称

r 复制代码
p <- p +
  geom_rect(
    data = cell_bar_df,
    aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax, fill = cell_state),
    color = NA,
    show.legend = FALSE
  ) +
  geom_text(
    data = cell_info,
    aes(x = 5.83, y = cell_state_y, label = cell_state),
    hjust = 1,
    size = 4.0,
    color = "black"
  )

第六步:添加右侧气泡图

r 复制代码
p <- p +
  annotate(
    "rect",
    xmin = panel_xmin,
    xmax = panel_xmax,
    ymin = panel_ymin,
    ymax = panel_ymax,
    fill = NA,
    color = "black",
    linewidth = 0.55
  ) +
  geom_point(
    data = dot_df,
    aes(x = dot_x, y = dot_y),
    color = dot_df$dot_col,
    size = dot_df$dot_size
  ) +
  geom_segment(
    data = x_ticks,
    aes(x = x, xend = x, y = panel_ymin, yend = panel_ymin - 1.15),
    linewidth = 0.45,
    color = "black"
  ) +
  geom_text(
    data = x_ticks,
    aes(x = x, y = panel_ymin - 3.0, label = sprintf("%.2f", ratio)),
    size = 2.85,
    color = "black"
  ) +
  annotate(
    "text",
    x = mean(c(panel_xmin, panel_xmax)),
    y = panel_ymin - 7.0,
    label = "Protein ratio",
    size = 3.8
  )

第七步:添加图例并导出

r 复制代码
p <- p +
  geom_tile(
    data = color_legend,
    aes(x = x, y = y),
    fill = color_legend$fill_col,
    width = 0.012,
    height = 1.6
  ) +
  annotate("text", x = panel_xmin + 0.54, y = 95.0, label = "-log10(p-value)", size = 3.4) +
  annotate("text", x = panel_xmin + 0.25, y = 87.7, label = "10", size = 3.0) +
  annotate("text", x = panel_xmin + 0.53, y = 87.7, label = "20", size = 3.0) +
  annotate("text", x = panel_xmin + 0.78, y = 87.7, label = "30", size = 3.0) +
  annotate("text", x = panel_xmin + 0.62, y = 81.5, label = "Protein count", size = 3.2) +
  geom_point(
    data = size_legend,
    aes(x = x, y = y),
    size = size_legend$point_size,
    color = "black"
  ) +
  geom_text(
    data = size_legend,
    aes(x = x, y = y - 4.6, label = count),
    size = 2.85
  ) +
  scale_color_manual(values = c(cell_cols, setNames(cell_info$cell_state_color, cell_info$cell_state_color))) +
  scale_fill_manual(values = cell_cols) +
  coord_cartesian(xlim = c(0, 8.70), ylim = c(-9.5, 132), clip = "off") +
  theme_void() +
  theme(
    legend.position = "none",
    plot.margin = margin(8, 8, 8, 5)
  )

ggsave("protein_cellstate_flow_bubble.png", p, width = 6.0, height = 8.2, dpi = 360, bg = "white")
ggsave("protein_cellstate_flow_bubble.pdf", p, width = 6.0, height = 8.2, bg = "white")

完整代码

r 复制代码
library(tidyverse)
library(scales)

links <- read_csv("protein_cellstate_links.csv", show_col_types = FALSE)
cell_info <- read_csv("cellstate_enrichment.csv", show_col_types = FALSE)

cell_levels <- cell_info$cell_state

links <- links |>
  mutate(cell_state = factor(cell_state, levels = cell_levels))

cell_info <- cell_info |>
  mutate(cell_state = factor(cell_state, levels = cell_levels))

cell_cols <- setNames(cell_info$cell_state_color, cell_info$cell_state)

panel_xmin <- 6.45
panel_xmax <- 8.35
panel_ymin <- 0
panel_ymax <- max(cell_info$cell_state_y + 4.8)

dot_df <- cell_info |>
  mutate(
    dot_x = rescale(
      protein_ratio,
      to = c(panel_xmin + 0.18, panel_xmax - 0.18),
      from = c(0, 0.18)
    ),
    dot_y = c(47, 35, 22, 11, 5),
    dot_col = col_numeric(
      palette = c("#1d2cff", "#7c169f", "#e23b2e", "#ff0000"),
      domain = c(5, 32)
    )(neg_log10_p),
    dot_size = rescale(protein_count, to = c(2.4, 10.5), from = c(5, 65))
  )

x_ticks <- tibble(
  ratio = c(0.05, 0.10),
  x = rescale(
    ratio,
    to = c(panel_xmin + 0.18, panel_xmax - 0.18),
    from = c(0, 0.18)
  )
)

color_legend <- tibble(
  x = seq(panel_xmin + 0.18, panel_xmin + 0.85, length.out = 80),
  y = 91,
  value = seq(5, 32, length.out = 80)
) |>
  mutate(
    fill_col = col_numeric(
      palette = c("#1d2cff", "#7c169f", "#e23b2e", "#ff0000"),
      domain = c(5, 32)
    )(value)
  )

size_legend <- tibble(
  x = c(panel_xmin + 0.18, panel_xmin + 0.70, panel_xmin + 1.26),
  y = 76,
  count = c(20, 40, 60),
  point_size = c(3.7, 5.6, 7.6)
)

cell_bar_df <- cell_info |>
  mutate(
    xmin = 5.98,
    xmax = 6.18,
    ymin = cell_state_y - 4.8,
    ymax = cell_state_y + 4.8
  )

p <- ggplot() +
  geom_curve(
    data = links,
    aes(
      x = 0.64,
      y = gene_y,
      xend = 5.86,
      yend = cell_state_y_link,
      color = cell_state
    ),
    curvature = -0.34,
    linewidth = 0.24,
    alpha = 0.36
  ) +
  geom_segment(
    data = links,
    aes(x = 0.50, xend = 0.62, y = gene_y, yend = gene_y, color = tick_color),
    linewidth = 1.0,
    show.legend = FALSE
  ) +
  geom_text(
    data = links,
    aes(x = 0.47, y = gene_y, label = protein_label),
    hjust = 1,
    size = 1.34,
    color = "black"
  ) +
  geom_rect(
    data = cell_bar_df,
    aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax, fill = cell_state),
    color = NA,
    show.legend = FALSE
  ) +
  geom_text(
    data = cell_info,
    aes(x = 5.83, y = cell_state_y, label = cell_state),
    hjust = 1,
    size = 4.0,
    color = "black"
  ) +
  annotate(
    "rect",
    xmin = panel_xmin,
    xmax = panel_xmax,
    ymin = panel_ymin,
    ymax = panel_ymax,
    fill = NA,
    color = "black",
    linewidth = 0.55
  ) +
  geom_point(
    data = dot_df,
    aes(x = dot_x, y = dot_y),
    color = dot_df$dot_col,
    size = dot_df$dot_size
  ) +
  geom_segment(
    data = x_ticks,
    aes(x = x, xend = x, y = panel_ymin, yend = panel_ymin - 1.15),
    linewidth = 0.45,
    color = "black"
  ) +
  geom_text(
    data = x_ticks,
    aes(x = x, y = panel_ymin - 3.0, label = sprintf("%.2f", ratio)),
    size = 2.85,
    color = "black"
  ) +
  annotate(
    "text",
    x = mean(c(panel_xmin, panel_xmax)),
    y = panel_ymin - 7.0,
    label = "Protein ratio",
    size = 3.8
  ) +
  geom_tile(
    data = color_legend,
    aes(x = x, y = y),
    fill = color_legend$fill_col,
    width = 0.012,
    height = 1.6
  ) +
  annotate("text", x = panel_xmin + 0.54, y = 95.0, label = "-log10(p-value)", size = 3.4) +
  annotate("text", x = panel_xmin + 0.25, y = 87.7, label = "10", size = 3.0) +
  annotate("text", x = panel_xmin + 0.53, y = 87.7, label = "20", size = 3.0) +
  annotate("text", x = panel_xmin + 0.78, y = 87.7, label = "30", size = 3.0) +
  annotate("text", x = panel_xmin + 0.62, y = 81.5, label = "Protein count", size = 3.2) +
  geom_point(
    data = size_legend,
    aes(x = x, y = y),
    size = size_legend$point_size,
    color = "black"
  ) +
  geom_text(
    data = size_legend,
    aes(x = x, y = y - 4.6, label = count),
    size = 2.85
  ) +
  scale_color_manual(values = c(cell_cols, setNames(cell_info$cell_state_color, cell_info$cell_state_color))) +
  scale_fill_manual(values = cell_cols) +
  coord_cartesian(xlim = c(0, 8.70), ylim = c(-9.5, 132), clip = "off") +
  theme_void() +
  theme(
    legend.position = "none",
    plot.margin = margin(8, 8, 8, 5)
  )

ggsave("protein_cellstate_flow_bubble.png", p, width = 6.0, height = 8.2, dpi = 360, bg = "white")
ggsave("protein_cellstate_flow_bubble.pdf", p, width = 6.0, height = 8.2, bg = "white")

复现结果


参考链接