Introduction
"总人口涨了"和"人口结构老了"是两件事,而且常常同时发生。百分比堆叠图把总量这一维主动扔掉,每个时间点都归一化成 100%,只留下构成。
这是它的全部价值,也是它最大的限制:看不出总量。所以它几乎总是要和一张总量图配着用,单独出现容易让人把"占比下降"读成"数量下降"。
读的时候按这个顺序:
- 高度不代表总量。 每一年都是满格 100%,图形的总高度是常数。
- 看色带厚度。 越厚占比越高。
- 沿时间追一条色带。 变厚是占比上升,变薄是下降。
- 注意中间的带更难读。 底部色带只有上边界在动,中间的上下边界都在动,眼睛很难判断它到底厚了没有。
- 回到绝对值。 占比降了,数量可能反而涨了。
Example Data
这张图要的输入是:时间 + 分类 + 数值,三列长表,一行是某个时间点某一类的数值。数值必须非负、可相加——归一化就是把同一时间点的各类除以它们的和。
population_age_composition 是 1950–2100 年 × 8 个年龄组的人口数。换成自己的数据,只要还是这三列就能直接跑。
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)
# 公开地址,和数据集页上「下载 CSV」给的是同一个 —— 不写仓库相对路径:
# 那个目录不进仓库,读者 clone 下来也没有这个文件,这段代码就跑不了
age_data <- read.csv("https://assets.evanzhou.org/tessera/csv/population_age_composition.csv") |>
mutate(Group = factor(Group, levels = stack_levels, ordered = TRUE))
# 每个时间点必须恰好 8 行。缺行是这张图唯一会静默变形的地方:
# 归一化按定义就凑满 100%,所以"每年加起来等于 1"这个检查永远通过、什么也查不出,
# 而某年少一组时,剩下 7 条带照样填满 100%,图上看就是一条色带凭空消失又出现。
# 真写成 Value = NA 反而好办 —— sum() 会让那一整年变成 NA,直接开天窗,一眼就看见
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)
)
Palettes
八个年龄组用 babel 的前 8 色。年龄顺序已经由堆叠位置稳定表达了,颜色只负责维持各条色带的身份。
babel 是定性色板,不把冷暖强行解释成年龄高低——如果用渐变色,读者会以为颜色深浅本身在编码什么。
两条顺序必须一致:图例顺序要跟堆叠顺序对齐,否则每看一次图例就要在脑子里翻一次;而最要紧的那一组应该放在底部,因为底部色带只有一条边界在动,是全图最好读的位置。
age_colors <- setNames(
get_palette("babel", type = "qualitative")[seq_along(stack_levels)],
stack_levels
)
Recipe
| No. | Method | Input Data | Palettes |
|---|---|---|---|
| 1 | ggplot2 |
age_percent |
age_colors |
| 2 | ggstream |
age_percent |
age_colors |
1 · ggplot2
比例要自己先算一次。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)) # 图例排一行,和堆叠顺序对得上
