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() # 换成完整主题,字号回到默认的 11theme_set 之后全局字号:12p + theme(legend.position = "top"):字号仍是 12p + theme_minimal():字号变成 11p + theme_minimal(base_size = 12):字号是 12theme() 与全局主题是叠加关系,写进它的项覆盖全局、没写的项继续用全局值。theme_minimal() 这类完整主题则是一次性替换:全局设的 base_size、图例位置、网格开关全部作废。要让全局字号在新主题下继续生效,得在括号里再写一遍。
theme() 微调
Section titled “theme() 微调”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.major和panel.grid.minor分别控制,删掉次网格是最常见的减负操作。margin = margin(t = 8, r = 4):element_text()支持外边距,标题贴着绘图区时用它撑开。单位默认 pt。strip.background与strip.text:分面标签条的底色和文字,分别对应element_rect()和element_text()。
图例位置、坐标轴线与网格的成组设置放在下一节,那里按「投稿图长什么样」整体给一份配置。
theme() 要写在 + 链的末尾。写在前面会被后面同名的设置覆盖,表现出来就是「我明明改了却没生效」——排查时先看是不是顺序问题,再看元素类型有没有用错(给 panel.grid.minor 传 element_text() 不会报错,只是不生效)。
坐标轴、网格与图例
Section titled “坐标轴、网格与图例”投稿图最常见的样子是白底、无网格、只留 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.x 和 panel.grid.major.y 分开控制纵向与横向网格。柱状图删掉纵向网格、只留横向,读者对高度的判断不受干扰。axis.text.x 的 angle 配 hjust = 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 的调色板在灰度打印时仍然保持明暗差异,RColorBrewer 的 Set2、Dark2 也是色盲友好的定性方案。手写颜色时推荐 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.infotable(info$category)div qual seq 9 8 18qual(定性,8 个)用于无序分组,Set2、Dark2、Paired 是其中常用的几个;seq(顺序,18 个)用于单调递增的量,Blues、Greens 这类单色渐变适合和期刊的黑白排版搭配;div(发散,9 个)用于有中心点的量,RdBu、PiYG 是常见的两个。对应到 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 8Returning 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() 命名向量里的名字必须和因子水平一致,写错了会报错或者得到灰色;fill 和 color 是两套互相独立的标度,箱线图的填充与描边要分别指定,否则描边仍是默认的黑色。
这一步最容易卡住,因为 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) 可以随时关掉这个全局开关。
输出:尺寸、dpi 与矢量图
Section titled “输出:尺寸、dpi 与矢量图”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() 不写 width 和 height 时,用的是 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 就该调);坐标轴上有没有写单位。
几个反复出现的坑
Section titled “几个反复出现的坑”元素名拼错不报错,只给警告。 theme() 里的元素名不在 ggplot2 的层级表里时,代码照常跑完,图也照常画,只是那一项设置被忽略:
p + theme(panel.gridd.minor = element_blank()) # 多打了一个 dWarning message:In plot_theme(plot) : The `panel.gridd.minor` theme element is not defined in the element hierarchy.出图脚本里警告容易被刷屏淹没,改完主题记得单独看一次警告。命名规律是先面板后元素:panel.grid.major、panel.grid.minor、axis.text.x、axis.ticks.y、legend.position、strip.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 ptp + 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/ 看两种「全局预设」思路的差别。