← 返回 Tessera
构成2 张图population_age_composition(Tessera Toy,1950–2100 × 8 个年龄组)2026-04-13

percentage_stacked

总量在变的同时,各部分的**占比**是怎么变的?

需要的输入
时间 + 分类 + 数值,三列长表
示例数据
population_age_composition(Tessera Toy,1950–2100 × 8 个年龄组)
依赖
ggplot2 · ggstream · scales · dplyr · biopalette
配色
babel#1688A7 #7673AE #B3DE69 #D195F6 #7E285E #8197FF #0911E9 #FF9E81
成图预览另有 1 张在配方里
percentage_stacked

什么时候用它

"总人口涨了"和"人口结构老了"是两件事,而且常常同时发生。百分比堆叠图把总量这一维主动扔掉,每个时间点都归一化成 100%,只留下构成。

这是它的全部价值,也是它最大的限制:看不出总量。所以它几乎总是要和一张总量图配着用,单独出现容易让人把"占比下降"读成"数量下降"。

怎么读

  1. 高度不代表总量。 每一年都是满格 100%,图形的总高度是常数。
  2. 看色带厚度。 越厚占比越高。
  3. 沿时间追一条色带。 变厚 = 占比上升,变薄 = 下降。
  4. 注意中间的带更难读。 底部色带只有上边界在动,中间的上下边界都在动,眼睛很难判断它到底厚了没有——这是堆叠图的固有缺陷,不是画错了。
  5. 回到绝对值。 占比降了,数量可能反而涨了。

常见陷阱

  • 占比下降 ≠ 数量下降。 这条值得写在图注里。
  • 类别多了追不动。 超过七八条色带,读者就没法跟住任何一条。
  • 堆叠顺序影响感知。 把最要紧的那一类放在底部——只有一条边界在动,最好读。
  • 图例顺序必须和堆叠顺序一致。 不一致的话,每看一次图例就要在脑子里翻一次。
  • stream 图读不出精确比例。 它的基线是浮动的,那正是它好看的原因,也是它不能拿来读数的原因。
  • 长表缺行会静默变形。 归一化按定义就凑满 100%,所以"每年加起来等于 1"这个检查永远通过、什么也查不出。风险在更上游:某年某组没数据时,真实数据里往往是整行不存在(而不是 Value = NA),于是那一年用 7 条带填满 100%,图上看就是一条色带凭空消失又出现。画之前先 count(Time) 数一遍每个时间点有几行。真是 NA 反而好办——sum() 会让那一整年变成 NA,直接开天窗,一眼就看见。

配色

八个年龄组使用 Tessera 的 babel 前 8 色。年龄顺序已经由堆叠位置稳定表达,颜色只负责维持各条色带的身份;不再为这份 recipe 临时造一套没有来源的 HEX。

babel 是定性色板,不把冷暖强行解释成年龄高低。最重要的组仍应放在底部,让固定基线承担主要比较任务;颜色顺序与图例、堆叠顺序必须保持一致。

配方

方法绘图系统什么时候选它
Ageom_area()ggplot2读者要读出具体比例——固定的 0–100% 基线才对得准
Bgeom_stream(type = "mirror")ggplot2(ggstream 扩展)要传达长期结构的流动感,不要求精确读数

数据准备

示例读取 Tessera Toy population_age_composition。这份 CSV 是由原 recipe 的确定性模拟正式冻结而来;真实项目仍只需替换为同样的 Time / Group / Value 三列长表。

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

age_levels <- c("<5", "5-14", "15-24", "25-34", "35-44", "45-54", "55-64", ">65")
stack_levels <- rev(age_levels)

age_data <- read.csv("content/tessera/data/csv/population_age_composition.csv") |>
  mutate(Group = factor(Group, levels = stack_levels, ordered = TRUE))

# 长表不能缺组:每个时间点必须恰好有 8 行,而且 Value 非负
rows_per_time <- age_data |> count(Time, name = "groups")
stopifnot(
  all(rows_per_time$groups == length(age_levels)),
  !anyNA(age_data[c("Time", "Group", "Value")]),
  all(age_data$Value >= 0)
)

age_colors <- setNames(
  get_palette("babel", type = "qualitative")[seq_along(stack_levels)],
  stack_levels
)

方法 A · geom_area()

先手动算一次比例。geom_area() 本身不会归一化,喂给它什么就画什么。

#| fig: area
#| fig-width: 10
#| fig-height: 6
age_percent <- age_data |>
  group_by(Time) |>
  mutate(Percent = Value / sum(Value)) |>
  ungroup()

ggplot(age_percent, aes(x = Time, y = Percent, fill = Group)) +
  geom_area(color = "white", linewidth = 0.1) +   # 白色细边把相邻色带断开
  scale_y_continuous(
    labels = percent_format(accuracy = 1),
    expand = c(0, 0)      # 去掉默认留白,让 0% 和 100% 贴住绘图区边界
  ) +
  scale_x_continuous(breaks = seq(1950, 2100, 25), expand = c(0, 0)) +
  scale_fill_manual(values = age_colors) +
  labs(
    title = "Population Age Composition",
    subtitle = "Tessera Toy: population_age_composition · percentage stacked area",
    x = "Year", y = "Proportion", fill = "Age Group"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    legend.position = "bottom",
    panel.grid.minor = element_blank(),
    plot.title.position = "plot",   # 标题左对齐到整张图,不是对齐到绘图区
    plot.title = element_text(face = "bold", hjust = 0, size = 16)
  ) +
  guides(fill = guide_legend(nrow = 1))   # 图例排一行,和堆叠顺序对得上
percentage_stacked — area
percentage_stacked-area

方法 B · geom_stream()

先使用方法 A 已算好的 Percent,再用 type = "mirror" 把每个时间点的 100% 总厚度对称放到零线上下。type = "proportional" 虽然会归一化,却仍从底部基线开始,并不是这里所说的居中 stream。

#| fig: stream
#| fig-width: 10
#| fig-height: 7
library(ggstream)

ggplot(age_percent, aes(x = Time, y = Percent, fill = Group)) +
  geom_stream(type = "mirror") +   # Percent 已归一化;mirror 只负责把总厚度居中
  scale_y_continuous(
    breaks = seq(-0.5, 0.5, 0.25),
    labels = percent_format(accuracy = 1),
    expand = expansion(mult = 0.04)
  ) +
  scale_x_continuous(
    breaks = seq(1950, 2100, 25),
    expand = expansion(mult = 0.01)
  ) +
  scale_fill_manual(values = age_colors) +
  labs(
    title = "Population Age Composition",
    subtitle = "Tessera Toy: population_age_composition · centered yearly shares",
    x = "Year", y = "Centered share", fill = "Age Group"
  ) +
  guides(fill = guide_legend(nrow = 1)) +
  theme_minimal(base_size = 13) +
  theme(
    legend.position = "bottom",
    panel.grid.minor = element_blank(),
    plot.title.position = "plot",
    plot.title = element_text(face = "bold", hjust = 0, size = 16)
  )
percentage_stacked — stream
percentage_stacked-stream

两种方法怎么选

  1. 要让人读出数字用 A。 固定的 0–100% 基线是能对齐的参照,stream 图那条浮动基线不是。
  2. 要表达"结构在流动"用 B。 它的对称扩散形态确实更能传达长期演变,适合做汇报开头那张图。
  3. 不要用 B 做结论图。 它牺牲的正是可读性——图上任何一条色带的厚度都得靠眼睛估,而且中间那几条尤其难估。
运行环境5 个包 · 2026-08-13T11:58:18.204+0800

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

  • biopalette 0.1.0
  • dplyr 1.2.1
  • ggplot2 4.0.3
  • ggstream 0.1.0
  • scales 1.4.0