没错,当我祭出 “哑铃” 阵列,你当如何破解,哈哈哈哈…此时,你可以适当怀疑笔者的精神状态了。但话说回来,如果稍加想象,把上图竖起来,“大致” 就是我要分享的 “哑铃图” 了。😑
哑铃图的优劣
哑铃图(Dumbbell Plot)是一种用于对比两组数据差异的可视化图表,通过线段连接两个数据点,形似哑铃。每种数据可视化的方法,都有其优劣,它也不例外,一个简单的总结:
✅ 优点
- 直观对比:突出两个时间点或组别之间的数值变化(如实验组和处理组的数据对比)。
- 简洁清晰:适合展示少量类别(5-10组),避免柱状图或折线图的密集重叠问题。
- 强调差异:线段长度和方向能快速传递增长/下降趋势,适合呈现差距明显的场景。
❌ 劣势
- 数据量限制:类别过多时易导致视觉混乱,超过15组通常不适用。
- 信息单一:仅展示两点关系,无法呈现多时间点趋势或复杂多维数据。
- 设计敏感:颜色、线段粗细若搭配不当可能误导解读(如浅色易弱化差异感知)。
笔者:可是很多时候优势即劣势。
接下来的内容,笔者会介绍如何在 R 中,利用 ggplot2
,一步一步实现哑铃图
准备工作
需要的 R Package
library(tidyr) # 数据处理
library(dplyr) # 数据处理
library(ggplot2) # 画图用的
library(patchwork) # 拼图用的
示例数据
raw <- tibble(
labels = c(
"Spirituality, faith and religion",
"自由而独立",
"Hobbies and recreation",
"身心健康",
"COVID-19",
"宠物",
"Nature and the outdoors"
),
Dem = c(8, 6, 13, 13, 8, 5, 5),
Rep = c(22, 12, 7, 9, 5, 2, 3)
)
一个基础的哑铃图
数据预处理
因为要用 ggplot2
进行可视化,所以必须要把数据格式转换为其需要的形式:长格式
df_long <- df %>%
pivot_longer(-labels)
绘图
很简单,想象一下,就是一条直线,两端挂着 “大大” 的点(线 + 点),线的长度代表两组的差异,点的颜色代表组别:
df_long %>%
ggplot(aes(x = value, y = labels)) +
geom_line(aes(group = labels), color = "#E7E7E7", linewidth = 3.5) +
geom_point(aes(color = name), size = 5) +
theme_minimal(base_size = 20) +
theme(
legend.position = "none",
axis.text.y = element_text(color = "black"),
axis.text.x = element_text(color = "#989898"),
axis.title = element_blank(),
panel.grid = element_blank()
) +
scale_color_manual(values = c("#436685", "#BF2F24")) +
scale_x_continuous(labels = scales::percent_format(scale = 1))
代码不是很多,就可以实现 哑铃图,但是,但是我们需要加一些细节,让它展示的信息更加直观,比如:
- y轴排序:默认情况下,
ggplot()
会对用于轴的字符向量按字母顺序排序,然后以逆字母顺序显示y轴值。但是我想按两组对比的差值降序排序。所以,我需要计算差距值,然后将y轴标签转换为factor
。 - 文本标签:在点的左边或者右边添加具体数值标签;默认情况下,图的右侧会有一个颜色图例,但在上面的代码中,通过
theme(legend.position = "none",)
,移除了图例,实际上,对于数据的展示来说,这是不可取的,所以,我想通过文本标签的方式,在第一行数据点的上方,加上可以提示组别的标签。 - 组间差值:我需要一个单独的图,可视化组间差值,使用
patchwork
将其与主图拼在一起。
加亿点点细节
数据重构
df <- raw %>% # 上边的数据
# 计算差值
mutate(gap = Rep - Dem) %>%
group_by(labels) %>%
mutate(max = max(Dem, Rep)) %>%
ungroup() %>%
# 排序
mutate(labels = forcats::fct_reorder(labels, abs(gap)))
# 转换为长数据
df_long <- df %>%
pivot_longer(
c(Dem, Rep)
)
df
数据大概长这样:
重绘
nudge_value <- .6
p_main <-
df_long %>%
# 前几行和之前一样
ggplot(aes(x = value, y = labels)) +
geom_line(aes(group = labels), color = "#E7E7E7", linewidth = 3.5) +
geom_point(aes(color = name), size = 5) +
# 添加数字标签,使用 ifelse,确定标签位置
geom_text(aes(label = value, color = name),
size = 5,
nudge_x = if_else(
df_long$value == df_long$max,
nudge_value,
-nudge_value
),
hjust = if_else(
df_long$value == df_long$max,
0,
1
),
) +
# 自制图例
geom_text(aes(label = name, color = name),
data = . %>% filter(gap == max(gap)),
nudge_y = .5,
fontface = "bold",
size = 5
) +
theme_minimal(base_size = 20) +
theme(
legend.position = "none",
axis.text.y = element_text(color = "black"),
axis.text.x = element_text(color = "#989898"),
axis.title = element_blank(),
panel.grid = element_blank()
) +
labs(x = "%", y = NULL) +
scale_color_manual(values = c("#436685", "#BF2F24")) +
coord_cartesian(ylim = c(1, 7.5)) +
scale_x_continuous(labels = scales::percent_format(scale = 1))
p_main
差值示意图
构建数据
df_gap <-
df %>%
mutate(
label = forcats::fct_reorder(labels, abs(gap)),
gap_party_max = if_else(
Rep == max,
"R",
"D"
),
gap_label =
paste0("+", abs(gap), gap_party_max) %>%
forcats::fct_inorder()
)
df_gap
绘图
p_gap <-
df_gap %>%
ggplot(aes(x = gap, y = labels)) +
geom_text(aes(x = 0, label = gap_label, color = gap_party_max), fontface = "bold", size = 5) +
annotate("text",
x = 0, y = 7.5, label = "Diff",
fontface = "bold", size = 7
) +
theme_void() +
coord_cartesian(xlim = c(-.05, 0.05), ylim = c(1, 7.5)) +
theme(
plot.margin = margin(l = 0, r = 0, b = 0, t = 0),
panel.background = element_rect(fill = "#EFEFE3", color = "#EFEFE3"),
legend.position = "none"
) +
scale_color_manual(values = c("#436685", "#BF2F24"))
p_gap
拼接:成品
p_whole <-
p_main + p_gap + plot_layout(
design =
c(
area(l = 0, r = 45, t = 0, b = 1), # 主图区域
area(l = 46, r = 52, t = 0, b = 1) # 差值图区域
)
)
p_whole
怎么样,看起来还行吧!
简单总结一下:制作基本图形相当容易,但添加细节以提高可读性则需要更多的调整