数据准备
示例读取 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)) # 图例排一行,和堆叠顺序对得上