PAYATHON 2026

如何用 ggplot2 排列多列热图并保留多个独立图例?

数据小陈

可以继续采用“每列一个 ggplot,再用 patchwork 拼接”的方式。把各列的行顺序和坐标范围统一,单独划出图例区域,再根据需要决定是否保留 coord_fixed()。

由于没有实际效果图和目标图,下面的代码先处理可以明确的问题:列间空白、行对齐、列标题和多个独立图例的位置。

为什么只把 margin 设为 0 还不够

plot.margin 只控制整张子图的外边距,图例、坐标轴和固定纵横比仍会影响空间分配。

每张子图都设置了 coord_fixed(ratio = 1),绘图区的纵横比因此受到约束。当导出尺寸与布局不匹配时,即使外边距为 0,也可能出现空白。panel.spacing 主要控制分面之间的间距,不能消除这些独立 ggplot 之间的所有空白。

guides = "collect" 可以集中摆放图例,但不会让不同变量共享同一个色标;内容相同的图例可能被去重。不同单位的数据仍应分别设置色标,并给图例加上明确的标题。

统一数据与行顺序

先生成所有列共用的数据,再固定 uniqueID 的顺序。下面计算均值时使用了 na.rm = TRUE,与原代码有所不同:原代码中的 mean() 遇到缺失值会返回 NA,随后 drop_na() 可能删除整个汇总行。

library(tidyverse)
library(patchwork)
library(palmerpenguins)

penguins_formatted <- penguins %>%
  drop_na(species, sex) %>%
  group_by(species, sex) %>%
  summarise(
    MEAN_BILL_LENGTH = mean(bill_length_mm, na.rm = TRUE),
    MEAN_BILL_DEPTH = mean(bill_depth_mm, na.rm = TRUE),
    MEAN_FLIPPER_LENGTH = mean(flipper_length_mm, na.rm = TRUE),
    MEAN_MASS = mean(body_mass_g, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  rownames_to_column(var = "uniqueID")

row_ids <- penguins_formatted$uniqueID

penguins_formatted <- penguins_formatted %>%
  mutate(uniqueID = factor(uniqueID, levels = row_ids))

这段代码先排除了 species 或 sex 缺失的记录。如果实际数据需要展示这些缺失类别,应保留它们并显式设置显示方式。如果某组的数值全部缺失,mean(..., na.rm = TRUE) 仍可能得到 NaN,需要按业务含义处理。

用一个函数生成所有热图列

把共用的坐标设置和主题放进函数,将变量、列标题和色标作为参数传入。增加到十几列时,就不必反复复制整段 theme()。

make_strip <- function(column, label, fill_scale) {
  ggplot(
    penguins_formatted,
    aes(x = 1, y = uniqueID, fill = .data[[column]])
  ) +
    geom_tile(
      width = 1,
      height = 1,
      colour = "black",
      linewidth = 0.25
    ) +
    fill_scale +
    scale_x_continuous(
      limits = c(0.5, 1.5),
      breaks = 1,
      labels = label,
      position = "top",
      expand = expansion(mult = 0, add = 0)
    ) +
    scale_y_discrete(
      limits = rev(row_ids),
      drop = FALSE,
      expand = expansion(mult = 0, add = 0.5)
    ) +
    theme_void() +
    theme(
      axis.text.x.top = element_text(
        angle = 90,
        hjust = 0,
        vjust = 0.5,
        size = 9,
        margin = margin(b = 4)
      ),
      panel.border = element_rect(
        colour = "black",
        fill = NA,
        linewidth = 0.4
      ),
      panel.background = element_rect(
        colour = NA,
        fill = "transparent"
      ),
      plot.background = element_rect(
        colour = NA,
        fill = "transparent"
      ),
      plot.margin = margin(0, 0, 0, 0),
      legend.title = element_text(size = 9),
      legend.text = element_text(size = 8)
    )
}

plots <- list(
  make_strip(
    "species", "Species",
    scale_fill_discrete(name = "Species")
  ),
  make_strip(
    "sex", "Sex",
    scale_fill_manual(
      name = "Sex",
      values = c(female = "white", male = "black")
    )
  ),
  make_strip(
    "MEAN_BILL_LENGTH", "Bill length",
    scale_fill_fermenter(
      name = "Bill length (mm)",
      type = "seq",
      palette = 2,
      direction = 1
    )
  ),
  make_strip(
    "MEAN_FLIPPER_LENGTH", "Flipper length",
    scale_fill_fermenter(
      name = "Flipper length (mm)",
      type = "seq",
      palette = 3,
      direction = 1
    )
  )
)

代码中的几处设置:

  • x = 1 是数值位置,因此使用 scale_x_continuous(),并明确给出一个单元格的横向范围。
  • 所有列使用相同的 scale_y_discrete(),即使某列存在缺失值,也会保留完整的行位置。
  • rev(row_ids) 让数据中的第一行显示在最上方。
  • 列名通过顶部刻度显示。原代码同时使用了 labs(x = ...) 和 axis.title = element_blank(),因此轴标题会被隐藏。
  • 暂时去掉 coord_fixed(),让各列填满分配到的绘图区。单元格可以是矩形,整体布局也更容易调整。

scale_fill_fermenter() 保留了原代码的分段色标。如果需要连续渐变,可以将相应色标替换为 scale_fill_distiller(),并按数据范围选择合适的配色。

为所有图例预留一个区域

把 guide_area() 作为最后一个布局单元,让收集到的图例集中放在右侧。

combined <- wrap_plots(
  c(plots, list(guide_area())),
  nrow = 1,
  widths = c(rep(1, length(plots)), 2.5),
  guides = "collect"
) &
  theme(
    legend.position = "right",
    legend.box = "vertical"
  )

combined

前四列使用相同的相对宽度,最后的 2.5 为图例区域预留空间。这个值需要根据图例文字的长度和数量调整,不表示固定的厘米数。

guide_area() 与热图列放在同一层 patchwork 布局中,便于接收收集后的图例。使用 & theme(...) 可以统一设置图例位置。

如果图例太多,右侧区域可能放不下。可以增加导出高度,或者重新安排图例区域;压缩热图列宽无法解决图例本身过高的问题。

按最终用途设置导出尺寸

先确定输出尺寸,再调整图例宽度和文字大小。下面的尺寸只是一个起点:

ggsave(
  "penguin_example.png",
  plot = combined,
  width = 12,
  height = 6,
  units = "in",
  dpi = 300,
  bg = "white"
)

# SVG export requires an available SVG device.
# ggsave(
#   "penguin_example.svg",
#   plot = combined,
#   width = 12,
#   height = 6,
#   units = "in",
#   device = "svg",
#   bg = "transparent"
# )

扩展到十几列时,在 plots 中继续添加 make_strip() 调用,同时增加输出宽度,避免列标题和热图格子被挤得过窄。

如果必须使用正方形单元格,可以在函数中加回 coord_fixed(ratio = 1),再根据行数、列数和图例占用调整导出尺寸。固定比例会重新限制面板形状,可能再次产生空白,需要结合整体尺寸检查。

不同单位的列应保留各自的数值范围和图例。只有确实希望比较标准化后的相对大小时,才适合先进行标准化,再统一使用同一个色标。

备注:内容仅供参考。