跳到正文

ggplot2主题定制:配色、中文字体与出版级图表输出

图上的元素分两类:一类由数据决定(点的位置、柱子的高度、颜色的分组),另一类和数据无关(背景、网格线、字体、图例摆在哪、标题多大)。主题(theme)只负责第二类。改主题永远不会改变图传达的事实,只会改变它看起来够不够专业——想通了这一点,就可以放心地反复试。

ggplot2 自带一组内置主题,在图上加一个 + 就能换:

library(ggplot2)
p <- ggplot(ToothGrowth, aes(x = factor(dose), y = len, fill = factor(dose))) +
geom_boxplot(width = 0.6)
p + theme_bw()
p + theme_minimal()
p + theme_classic()
theme_bw() 输出:白色背景,绘图区带黑色边框,保留浅灰网格线。
theme_minimal() 输出:白色背景,没有边框,只剩浅灰网格线,整体最干净。
theme_classic() 输出:白色背景,只保留 x、y 两条坐标轴线,没有任何网格线,
接近 base R 的观感。

三个主题的差别只在背景、边框、网格线上,数据部分一模一样。选哪个看用途:投期刊用 theme_classic()theme_minimal(),编辑部通常不喜欢灰底;做组会幻灯片反而可以用默认的 theme_grey(),灰底配白网格在投影仪上更清楚。还有 theme_light()theme_dark()theme_void()(去掉一切只留数据)可用。

整个脚本要统一风格,用 theme_set() 设一次,后续所有图自动套用:

theme_set(theme_bw(base_size = 12))

base_size 是整套主题的字号基准(默认 11),所有文字按它等比缩放。这里有个容易被忽略的联动:图上文字的实际大小取决于图幅。同一个 12 pt,在 6 英寸宽的图里读起来舒服,压到 3 英寸就相当于 6 pt,印出来看不清。所以先定图幅(见下面 ggsave 那节),再定字号。

九个内置主题的差别可以列成一张表:

主题 背景 网格线 绘图区边框 适用场景
theme_grey() 灰底 白色 默认值,屏幕上查看
theme_bw() 白底 浅灰 四周闭合 通用,投稿可用
theme_linedraw() 白底 深灰 四周闭合 黑白打印
theme_light() 白底 浅灰细线 浅灰 theme_bw()
theme_dark() 深灰底 浅色 深色幻灯片
theme_minimal() 白底 浅灰 最干净,投稿常用
theme_classic() 白底 只有 x、y 两条轴线 接近 base R 观感
theme_test() 白底 四周闭合黑框 草稿阶段核对绘图区边界
theme_void() 全无 地图、示意图

theme_test() 的框线用于确认绘图区的实际范围,定稿前换掉。theme_void() 连坐标轴一起删,只在图本身不需要刻度时用。

换主题时有一个容易被忽略的规则:在图上加一个完整主题,会替换 theme_set() 设的全局主题,而不是在它上面叠加。

theme_set(theme_bw(base_size = 12))
p + theme(legend.position = "top") # 只覆盖图例位置,字号继承全局
p + theme_minimal() # 换成完整主题,字号回到默认的 11
theme_set 之后全局字号:12
p + theme(legend.position = "top"):字号仍是 12
p + theme_minimal():字号变成 11
p + theme_minimal(base_size = 12):字号是 12

theme() 与全局主题是叠加关系,写进它的项覆盖全局、没写的项继续用全局值。theme_minimal() 这类完整主题则是一次性替换:全局设的 base_size、图例位置、网格开关全部作废。要让全局字号在新主题下继续生效,得在括号里再写一遍。

theme() 里每一项对应图上的一个元素,赋值时要用对应的 element 函数。常用的就四个:

函数 作用对象 常用参数
element_text() 一切文字 family、size、face、color、angle、hjust、margin
element_line() 线:网格线、坐标轴、刻度线 color、linewidth、linetype
element_rect() 矩形:绘图区背景、图例背景、分面标签条 fill、color、linewidth
element_blank() 移除某个元素 无参数

一个典型的「出版级」调整:

p +
labs(
title = "维生素 C 对豚鼠牙生长的影响",
subtitle = "ToothGrowth 数据,每个剂量 20 只",
x = "剂量 (mg/day)",
y = "牙长 (mm)"
) +
theme_minimal(base_size = 13) +
theme(
plot.title = element_text(face = "bold"),
plot.subtitle = element_text(color = "grey40"),
panel.grid.minor = element_blank(),
legend.position = "top"
)
图形输出:白色背景、无外框,只剩浅灰色主网格线(主网格之间那条更细的次网格线被移除)。
标题加粗,副标题为灰色。图例从右侧移到顶部,三个色块横向排成一行。
三个箱体仍按剂量分组,颜色与图例一致。

几个高频设置:

  • panel.grid.minor = element_blank():网格密度由 panel.grid.majorpanel.grid.minor 分别控制,删掉次网格是最常见的减负操作。
  • margin = margin(t = 8, r = 4)element_text() 支持外边距,标题贴着绘图区时用它撑开。单位默认 pt。
  • strip.backgroundstrip.text:分面标签条的底色和文字,分别对应 element_rect()element_text()

图例位置、坐标轴线与网格的成组设置放在下一节,那里按「投稿图长什么样」整体给一份配置。

theme() 要写在 + 链的末尾。写在前面会被后面同名的设置覆盖,表现出来就是「我明明改了却没生效」——排查时先看是不是顺序问题,再看元素类型有没有用错(给 panel.grid.minorelement_text() 不会报错,只是不生效)。

投稿图最常见的样子是白底、无网格、只留 x 和 y 两条坐标轴线。theme_classic() 一次做到这一步,再用 theme() 调线条粗细:

p +
theme_classic(base_size = 12) +
theme(
axis.line = element_line(linewidth = 0.4, colour = "black"),
axis.ticks = element_line(linewidth = 0.4),
axis.ticks.length = unit(-0.1, "cm"),
plot.margin = margin(6, 6, 6, 6)
)

axis.ticks.length 给负值把刻度线画进绘图区内侧,这是期刊图里常见的样式,正负号决定朝向。plot.margin 的单位是 pt,四值顺序为上、右、下、左。

网格线的删除比整体换主题更细:

p + theme(
panel.grid.major.x = element_blank(),
panel.grid.minor = element_blank(),
axis.text.x = element_text(angle = 45, hjust = 1, size = 9)
)

panel.grid.major.xpanel.grid.major.y 分开控制纵向与横向网格。柱状图删掉纵向网格、只留横向,读者对高度的判断不受干扰。axis.text.xanglehjust = 1 让长类别名斜排;只写 angle 不写 hjust,文字整体左偏半个身位,末端会脱离刻度。

图例要调的主要是位置、方向和边框:

  • legend.position"right"(默认)、"top""bottom""none",或 c(0.8, 0.2) 这样的坐标把图例放进绘图区内部。图例放顶部时右边留白消失,图能画得更宽。
  • legend.direction:默认随位置自动判断,放顶部时通常要显式写 "horizontal"
  • legend.title = element_blank():分组名已经在坐标轴或图注里说明时,删掉图例标题省一行。
  • legend.key.size = unit(0.4, "cm"):图例色块的大小,色块偏大时调小。
  • legend.margin:图例四周的外边距,和 plot.margin 抵消时用它压紧。

theme(geom = element_geom(...)) 是 ggplot2 4.0 引入的一组默认值,控制所有几何对象的通用外观:

p + theme(geom = element_geom(ink = "#0072B2", paper = "grey95", accent = "#D55E00"))

ink 是点、线、描边的默认颜色,paper 是填充色,accent 是第二强调色。设一次,之后不加 colour =geom_point()geom_line() 都用这套颜色,作用相当于把默认配色换成自定义方案。单个几何对象的默认值也能改,用 update_geom_defaults("point", list(colour = "#0072B2")),但它只作用于该几何对象,且改的是包内部的默认值,影响范围比 element_geom() 更难追踪。

离散变量和连续变量的配色要分开考虑。

离散分组(处理组、物种、剂量)优先选对色觉缺陷友好的定性调色板:

p + scale_fill_viridis_d(option = "D", begin = 0.2, end = 0.85)
p + scale_fill_brewer(palette = "Set2")
p + scale_fill_manual(values = c("0.5" = "#0072B2", "1" = "#D55E00", "2" = "#009E73"))
scale_fill_viridis_d() 输出:三个箱体填充深紫、蓝绿、黄绿,图例颜色同步。
scale_fill_brewer(palette = "Set2") 输出:三个箱体为柔和的绿、橙、蓝紫,饱和度较低。
scale_fill_manual() 输出:三个箱体依次为蓝色、朱红、绿色(Okabe-Ito 配色,
在常见色觉缺陷下仍可区分)。

viridis 的调色板在灰度打印时仍然保持明暗差异,RColorBrewerSet2Dark2 也是色盲友好的定性方案。手写颜色时推荐 Okabe-Ito 那一套(#0072B2#D55E00#009E73#CC79A7 等),它的设计目标就是各种色觉条件下都能分辨。

viridis 有八个可选方案,用 option 参数切换,各自取三色的结果如下:

option 名称 三色取样(深 → 浅)
"A" magma #000004 #B63679 #FCFDBF
"B" inferno #000004 #BB3754 #FCFFA4
"C" plasma #0D0887 #CC4678 #F0F921
"D" viridis #440154 #21908C #FDE725
"E" cividis #00204D #7C7B78 #FFEA46
"F" rocket #03051A #CB1B4F #FAEBDD
"G" mako #0B0405 #357BA2 #DEF5E5
"H" turbo #30123B #A2FC3C #7A0403

"D"(viridis)和 "E"(cividis)在色觉缺陷下的表现最好,"E" 是专为红绿色盲调整过的版本。"H"(turbo)虽然也是 viridis 家族,却是彩虹式的色相变化,前面说的「造出不存在的边界」问题在这里同样存在,连续量不要用它。离散分组用 scale_fill_viridis_d(),连续量用 scale_fill_viridis_c(),两个函数的 option 取值一致。

RColorBrewer 的调色板按用途分成三类,共 35 个:

info <- RColorBrewer::brewer.pal.info
table(info$category)
div qual seq
9 8 18

qual(定性,8 个)用于无序分组,Set2Dark2Paired 是其中常用的几个;seq(顺序,18 个)用于单调递增的量,BluesGreens 这类单色渐变适合和期刊的黑白排版搭配;div(发散,9 个)用于有中心点的量,RdBuPiYG 是常见的两个。对应到 ggplot2 就是 scale_fill_brewer(palette = )scale_fill_distiller(palette = ),前者给离散变量、后者给连续变量。每个调色板能提供的最大颜色数记在 info$maxcolors 里,定性调色板一般是 8 到 12 个。分组数超过上限时不会报错,只给一条警告,然后返回一个重新生成的调色板:

d <- data.frame(g = factor(1:12), y = 1:12)
ggplot(d, aes(g, y, fill = g)) +
geom_col() +
scale_fill_brewer(palette = "Set2")
Warning message:
In RColorBrewer::brewer.pal(n, pal) :
n too large, allowed maximum for palette Set2 is 8
Returning the palette you asked for with that many colors

图能画出来,但那 12 个颜色已经不是在色觉缺陷条件下逐一手工校准过的那一组,相邻类别可能变得难以区分。分组数超过 8 个时,要么换 Paired(上限 12),要么回到前面说的做法:分组太多时改小图或换图形,而不是继续往配色上加颜色。

连续变量用渐变,但要选对类型:单调递增的量(浓度、GDP)用 scale_fill_viridis_c()scale_fill_gradient(low = , high = );有明确中点的量(相对均值的偏差、正负相关)用发散型 scale_fill_gradient2(low = , mid = "white", high = ),让颜色在中点两侧对称展开。不要用彩虹色(rainbow、jet)表示连续量——人眼对亮度差异的分辨力远高于色相,彩虹配色既不是单调的明暗变化,还会在图上造出根本不存在的「边界」。

还有两个细节。scale_fill_manual() 命名向量里的名字必须和因子水平一致,写错了会报错或者得到灰色;fillcolor 是两套互相独立的标度,箱线图的填充与描边要分别指定,否则描边仍是默认的黑色。

这一步最容易卡住,因为 R 的字体处理和操作系统绑得很死。按平台分开处理。

Windows 下先用 windowsFonts() 注册,再在主题里引用注册名:

windowsFonts(hei = windowsFont("SimHei"))
p + theme(text = element_text(family = "hei"))

windowsFonts() 只在 Windows 上存在,在别的系统运行会直接报错。存图时如果中文变成方框,或者提示字体嵌入失败,把设备换成 cairo:

ggsave("fig.png", width = 6, height = 4, dpi = 300, type = "cairo")
ggsave("fig.pdf", width = 6, height = 4, device = cairo_pdf)

macOS 下不需要注册,系统字体可以直接按家族名引用:

p + theme(text = element_text(family = "STHeiti"))

系统自带的中文字体都可以试,比如 "STHeiti""PingFang SC"。名字写错时 R 不会报错,只是静默回退到默认字体,表现出来就是中文变方框——所以换完字体一定要看一眼图,别只看代码有没有报错。

网上流传的 par(family = "STHeiti") 对 ggplot2 无效。par() 设置的是 base R 图形设备的参数,而 ggplot2 走的是 grid 图形系统,不读这些参数。在 base R 的 plot() 里管用,换成 ggplot2 就白写了。

跨平台最稳的方案是 showtext 包,它把文字转成矢量轮廓再画进图形,不依赖系统的字体库:

library(showtext)
# 需要字体文件的实际路径;Windows 一般在 C:/Windows/Fonts/ 下
font_add("notosc", regular = "/path/to/NotoSansSC-Regular.otf")
showtext_auto()
p + theme(text = element_text(family = "notosc"))
# 存图前把 dpi 同步给 showtext,否则文字和图的比例会错位
showtext_opts(dpi = 300)
ggsave("fig.png", width = 6, height = 4, dpi = 300)

代价是文字被画成了图形轮廓:PDF 里的字不能再被检索或复制,也无法在 Illustrator 里直接编辑文字内容。图注里要写字号规范、或者编辑部要求文字可编辑时,还是老实用系统字体加 cairo 设备。showtext_auto(FALSE) 可以随时关掉这个全局开关。

ggsave("fig1.png", plot = p, width = 6, height = 4, dpi = 300)
ggsave("fig1.pdf", plot = p, width = 6, height = 4)
ggsave("fig1.svg", plot = p, width = 6, height = 4)

选格式看图的类型。折线图、散点图、柱状图属于线条图(line art),用矢量格式:PDF、SVG 放大不会糊,文件只有几十 KB,编辑部还能拿去继续排版。照片、热图、有半色调效果的图才必须用位图,这类图期刊一般要求 300 dpi 起步,线条图常要求 600–1200 dpi。

dpi 只影响位图,文件体积近似线性增长。一张 3.5 × 2.5 英寸的箱线图存成 PNG:

dpi 像素尺寸 PNG 体积
72 252 × 180 7.3 KB
150 525 × 375 15.8 KB
300 1050 × 750 31.9 KB
600 2100 × 1500 64.9 KB

像素尺寸等于英寸数乘 dpi,所以调 dpi 不改变图的物理尺寸,也不改变文字相对于图的大小,只改变清晰度。同一张图存成 PDF 是 5.0 KB,不到 300 dpi PNG 的六分之一。矢量格式按路径存线条而不是按像素存位图,这是体积差距的来源。

存 SVG 要多装一个 svglite 包,否则 ggsave("fig.svg") 直接报错:

ggsave("fig.svg", plot = p, width = 3.5, height = 2.5)
Error in dev(filename = filename, width = dim[1], height = dim[2], bg = bg, :
The package "svglite" is required to save as SVG.

投稿用 PDF 就够了,SVG 主要面向需要在 Illustrator 里继续编辑的场合。

还有一个默认值容易吃亏:ggsave() 不写 widthheight 时,用的是 7 × 7 英寸的正方形画布,不是当前图的形状:

ggsave("fig.png", plot = p, dpi = 300)
Saving 7 x 7 in image

横排的图会被塞进正方形画布,上下留出大片空白;字号也因为画布变大而显得偏小。每次写全尺寸,或者把尺寸存成一个向量复用。

尺寸按最终排版宽度设,不要按屏幕感觉设。期刊单栏宽约 8.5 cm(3.35 英寸),通栏约 17–18 cm。先知道图会被排成多宽,再把 width 设成那个值,字号才是你看到的字号。常见错误是按 10 英寸存图,排版时被压到 3.5 英寸,所有文字跟着缩到三分之一,印出来只有 4 pt。

存 PDF 时中文还有一个坑:R 自带的 pdf() 设备对中文字体的支持不好,容易出方框或乱码;cairo_pdf 支持得更完整,也会把字体嵌进文件,换台机器打开不会掉字体。

判断一张图够不够「出版级」,可以看三条:去掉颜色后信息还在不在(形状、线型、位置是否仍在传递分组信息);按最终尺寸打印出来,最小的字还认不认得(低于 7 pt 就该调);坐标轴上有没有写单位。

元素名拼错不报错,只给警告。 theme() 里的元素名不在 ggplot2 的层级表里时,代码照常跑完,图也照常画,只是那一项设置被忽略:

p + theme(panel.gridd.minor = element_blank()) # 多打了一个 d
Warning message:
In plot_theme(plot) :
The `panel.gridd.minor` theme element is not defined in the element hierarchy.

出图脚本里警告容易被刷屏淹没,改完主题记得单独看一次警告。命名规律是先面板后元素:panel.grid.majorpanel.grid.minoraxis.text.xaxis.ticks.ylegend.positionstrip.background

删错了对象。 想删的东西和实际删掉的东西经常不是一回事,三组容易混的:

想删掉 应该写的元素
刻度数字 axis.text
刻度线 axis.ticks
轴标题 axis.title
分面标签 strip.text
图例标题 legend.title

axis.text = element_blank() 只去掉数字,轴标题还在;想连轴标题一起去掉,两个都要写。

绝对字号与相对字号。 element_text(size = 8) 是绝对的 8 pt,base_size 改成多少它都不变。要让某个元素跟着 base_size 等比缩放,用 rel() 包裹:

p + theme_bw(base_size = 20) + theme(axis.text = element_text(size = 8)) # 固定 8 pt
p + theme_bw(base_size = 20) + theme(axis.text = element_text(size = rel(0.5))) # 20 × 0.5 = 10 pt

投稿图要求刻度标签不低于 7 pt,用绝对字号更可控——字号是不是达标,看一眼 size 的数值就知道,不用乘一遍。

theme_update() 改全局,theme_set() 换全局。 两者名字接近,行为不同:

theme_set(theme_bw(base_size = 11))
theme_update(legend.position = "top")
theme_set(theme_classic()) # 整体重置,legend.position 回到 "right"

theme_update() 是往当前全局主题里补几项,已经设过的其他项保持不动;theme_set() 是整体替换,之前补的项全部失效。脚本里想统一改图例位置用前者,想换一整套外观用后者。两个都会影响之后画的每一张图,不只是紧接着的那一张。

下一篇转向交互式图表,用 plotly 和 leaflet 做能在浏览器里缩放、悬停的图,见 /r/visualization/interactive/。Python 侧的主题与配色是另一套体系(rcParams 全局参数加 style sheet),可以对照 /python/visualization/matplotlib-basics/ 看两种「全局预设」思路的差别。