Folioevanzhou.org
← 返回 Tessera
R4 张图2026-08-11

pie

A whole divided into a few mutually exclusive slices, each one's angle carrying its share of the total.

示例数据
hair_eye_color
配色
walter_white2
语言
R
成图预览另有 3 张在配方里
pie

Introduction

饼图用角度和面积表达一个整体的构成,回答的是"总量里大约有多少属于每一类"。它最适合类别少、差异明显、各部分相加严格等于整体的场景;这里把 592 名学生按眼睛颜色汇总成四个互斥类别。

ggplot2 没有 geom_pie()。饼图本质上是把一根 100% 堆叠柱卷成圆:geom_col() 按计数画出一根堆叠柱,coord_polar(theta = "y") 把柱高方向映射为圆周角度,于是每个扇区的角度等于该组计数占总数的比例。下面的 recipe 把汇总、因子顺序和标注计算都显式写出来,每一步的数据含义都能查。

读的时候按这个顺序:

  1. 先确认分母。 标题或图注应说明"整体"究竟是谁,以及总样本量 n。
  2. 先看大块,再看小块。 饼图可靠表达的是明显的构成差异,不是小数点后的精确排名。
  3. 用标签读取数值。 角度给整体印象,百分比标签承担相对精确的读数。
  4. 检查是否完整。 所有扇区必须互斥,并且合计为 100%;缺失类别不能被悄悄排除。

Example Data

饼图要的输入是:1 个分类变量,或 1 个分类变量 + 1 个非负计数变量。计数必须非负、可相加,而且这几个类别加起来就是整体——少一类,剩下的百分比全是错的。

hair_eye_color 已经是 Hair × Eye × Sex × Freq 的汇总长表。这里按 Eye 把发色和性别合并掉,再核对一次总数和比例之和。

library(dplyr)
library(ggplot2)
library(scales)
library(ggforce)
library(biopalette)

# 公开地址,和数据集页上「下载 CSV」给的是同一个 —— 不写仓库相对路径:
# 那个目录不进仓库,读者 clone 下来也没有这个文件,这段代码就跑不了
d <- read.csv("https://assets.evanzhou.org/tessera/csv/hair_eye_color.csv")

# 显式定顺序:四个是名义类别,本来无所谓先后,
# 固定下来是为了扇区、图例和另外三张图从头到尾对得上
eye_levels <- c("Brown", "Blue", "Hazel", "Green")

eye_total <- d |>
  summarise(count = sum(Freq), .by = Eye) |>
  mutate(Eye = factor(Eye, levels = eye_levels)) |>
  arrange(Eye) |>
  mutate(
    share = count / sum(count),
    label = percent(share, accuracy = 1)
  )

# 归一化按定义就凑得满 100%,所以"比例加起来是 1"永远成立、查不出漏类别。
# 真正要核对的是总数 —— 592 是这份数据的全样本
if (sum(eye_total$count) != 592 || abs(sum(eye_total$share) - 1) > 1e-12) {
  stop("眼睛颜色汇总未覆盖完整样本")
}

Palettes

眼睛颜色是四个无序类别,用定性色板 walter_white2 的前四色。相邻扇区之间加一道细窄的纸白边线,让大面积色块保持清楚边界;边线只负责分隔,替代不了颜色本身的可区分。

色板不够时不能从头循环取色——两个不同类别拿到同一个颜色,图上看就是同一类。类别多到色板装不下,该做的是合并次要类别或换柱图。

扇区面积比散点更容易暴露文字对比问题:同一种文字颜色在深扇区上够黑、在浅扇区上就糊了。所以标签版给每个扇区单独指定深字或白字,换色板时这一步要重新检查。

eye_colors <- setNames(
  get_palette("walter_white2", type = "qualitative")[1:4],
  eye_levels
)

# 四张图共用一套版式,theme_void() 打底 —— 饼图不需要坐标轴和网格
pie_theme <- theme_void(base_size = 13) +
  theme(
    plot.title = element_text(face = "bold", size = 15, hjust = 0),
    plot.subtitle = element_text(color = "grey35", hjust = 0),
    legend.title = element_text(face = "bold"),
    legend.text = element_text(size = 10.5),
    plot.margin = margin(14, 22, 14, 22)
  )

Recipe

No. Method Input Data Palettes
1 ggplot2 eye_total eye_colors
2 ggplot2 eye_total eye_colors
3 ggplot2 eye_total eye_colors
4 ggforce exploded_data eye_colors

1 · 基础饼图

先让扇区只承担构成关系,精确计数交给图例和配套文字。

position_stack(reverse = TRUE) 和图例采用同一类别顺序,第一类从十二点方向开始,阅读顺序才稳定。

#| fig: basic
#| fig-width: 7.2
#| fig-height: 5.4
ggplot(eye_total, aes(x = "", y = count, fill = Eye)) +
  geom_col(
    width = 1,
    color = "#F4F3EE",     # 纸白边线,只用来断开相邻色块
    linewidth = 0.9,
    position = position_stack(reverse = TRUE)
  ) +
  # theta = "y" 把柱高映射成圆周角;start = 0 让第一类从十二点开始
  coord_polar(theta = "y", start = 0, direction = 1) +
  scale_fill_manual(values = eye_colors, breaks = eye_levels) +
  labs(
    title = "Eye color composition",
    subtitle = "592 students · HairEyeColor",
    fill = "Eye color"
  ) +
  pie_theme
pie — basic
pie-basic

2 · 在扇区内标百分比

百分比比原始人数更贴合饼图的任务:它本来就只负责比例。

#| fig: labeled
#| fig-width: 7.2
#| fig-height: 5.4
# 深扇区用白字、浅扇区用深字。固定一种文字颜色,总有一块会糊
eye_total <- eye_total |>
  mutate(label_color = ifelse(Eye %in% c("Brown", "Blue"), "#FFFFFF", "#262622"))

ggplot(eye_total, aes(x = "", y = count, fill = Eye)) +
  geom_col(
    width = 1,
    color = "#F4F3EE",
    linewidth = 0.9,
    position = position_stack(reverse = TRUE)
  ) +
  geom_text(
    aes(label = label, color = label_color, group = Eye),
    # 和扇区用同一套 reverse 堆叠,否则标签会落到别人的扇区里
    position = position_stack(vjust = 0.5, reverse = TRUE),
    size = 4,
    fontface = "bold",
    show.legend = FALSE
  ) +
  coord_polar(theta = "y", start = 0, direction = 1) +
  scale_fill_manual(values = eye_colors, breaks = eye_levels) +
  scale_color_identity() +   # label_color 存的就是色值本身,不要再映射一次
  labs(
    title = "Eye color composition",
    subtitle = "Percent of 592 students",
    fill = "Eye color"
  ) +
  pie_theme
pie — labeled
pie-labeled

3 · 圆环

圆环没有增加任何统计信息,只是用中心留白换取更轻的视觉重量。孔越大扇区面积越小,小类别也越难辨认。

#| fig: donut
#| fig-width: 7.2
#| fig-height: 5.4
ggplot(eye_total, aes(x = 2, y = count, fill = Eye)) +   # x = 2 把堆叠柱推到外圈
  geom_col(
    width = 0.72,
    color = "#F4F3EE",
    linewidth = 0.9,
    position = position_stack(reverse = TRUE)
  ) +
  geom_text(
    aes(label = label, color = label_color, group = Eye),
    position = position_stack(vjust = 0.5, reverse = TRUE),
    size = 3.8,
    fontface = "bold",
    show.legend = FALSE
  ) +
  coord_polar(theta = "y", start = 0, direction = 1) +
  xlim(0.65, 2.45) +   # 下界决定中央孔径:调大孔更大,色带更细
  scale_fill_manual(values = eye_colors, breaks = eye_levels) +
  scale_color_identity() +
  labs(
    title = "Eye color composition",
    subtitle = "The same data as a donut chart",
    fill = "Eye color"
  ) +
  pie_theme
pie — donut
pie-donut

4 · 抽出一个重点扇区

Exploded pie 靠平移扇区强调一个事先确定的类别。这里只把 Green 移出。

偏移量是版式参数,不编码任何数值。别按结果临时挑一块抽出来,也别同时拉开好几块——那会破坏整体轮廓,又没有清楚的重点。

ggforce::geom_arc_bar() 允许每个扇区有自己的圆心。起止角仍严格来自累计比例,只有 Green 的 x0 / y0 沿扇区中线移动。

#| fig: exploded
#| fig-width: 7.2
#| fig-height: 5.4
exploded_data <- eye_total |>
  mutate(
    # 累计比例换算成弧度,角度仍然完全由数据决定
    end = cumsum(share) * 2 * pi,
    start = lag(end, default = 0),
    middle = (start + end) / 2,
    # 只有这一行是版式:沿扇区中线把圆心推出去一点
    offset = ifelse(Eye == "Green", 0.12, 0),
    x0 = sin(middle) * offset,
    y0 = cos(middle) * offset
  )

ggplot(exploded_data) +
  geom_arc_bar(
    aes(
      x0 = x0,
      y0 = y0,
      r0 = 0,      # 内半径为 0 就是实心饼;给个正数就成了偏移版圆环
      r = 1,
      start = start,
      end = end,
      fill = Eye
    ),
    color = "#F4F3EE",
    linewidth = 0.9
  ) +
  # 必须锁定纵横比,否则圆会被拉成椭圆,面积比例跟着失真
  coord_fixed(xlim = c(-1.28, 1.28), ylim = c(-1.18, 1.18), clip = "off") +
  scale_fill_manual(values = eye_colors, breaks = eye_levels) +
  labs(
    title = "Eye color composition",
    subtitle = "Green highlighted with a single exploded slice",
    fill = "Eye color"
  ) +
  pie_theme
pie — exploded
pie-exploded

Constraints

  • 饼图不是精确比较工具。 人眼对共同基线上的长度敏感,对扇区角度的细小差异不敏感。37% 和 36% 靠角度分不出来,Brown 和 Blue 这种相近比例只能读成"接近"。要精确排序或比较百分点差异,改用从零开始的柱状图。
  • 各部分必须互斥且合计为 100%。 少算一类、或者类别之间有重叠,剩下所有扇区的角度就都是错的,而图上完全看不出来。
  • 类别超过五六个就读不动了。 颜色、图例和小扇区会一起失去可读性。该做的是合并有意义的次要类别,不是换一套更花的配色。
  • 有自然顺序的类别别按大小重排。 流程阶段、剂量等级这类顺序本身就是信息,按份额从大到小排会把它抹掉。
  • 别加三维和透视。 透视让靠近观察者的扇区显得更大,直接扭曲面积判断——而面积是饼图唯一的编码。

四个 recipe 怎么比

  1. 图很小,或正文已经给出数字 → recipe 1,保留最干净的整体构成。
  2. 读者需要直接读取比例 → recipe 2,这是最完整也最推荐的版本。
  3. 版面需要更轻的块面,或中心需要留白 → recipe 3。它是视觉变体,不比普通饼图更准确。
  4. 有一个事先定义的重点类别 → recipe 4,只抽出这一块,并在标题或图注里说明原因。
运行环境5 个包 · 2026-09-02T12:32:12.535+0800

R version 4.5.1 (2025-06-13 ucrt) · x86_64-w64-mingw32

  • biopalette 0.2.2
  • dplyr 1.2.1
  • ggforce 0.5.0
  • ggplot2 4.0.3
  • scales 1.4.0