Introduction
分级填色地图适合看空间格局:高值或低值是否连成区域,相邻国家是否呈现共同模式。地图的价值只来自位置;要精确比较具体数值,排序柱状图通常更诚实。
它不适合表达总量。大国在画布上天然更抢眼,俄罗斯和新加坡即使数值相同,视觉重量也相差悬殊。人口、GDP 总量这类指标还会和国土面积产生额外联想,所以这里选人均 GDP,减少"面积大所以总量大"的误读。
读的时候按这个顺序:
- 先看成片区域。 地图用于识别空间聚集,不用于给国家排精确名次。
- 确认基准。 图例里的 Median 是全球有效经济体的中位数,不是零美元;它是读数刻度,不是色板的中性色。
- 看图例是连续还是分箱。 分箱边界是人为的,跨过边界不代表现实中突然发生跃迁。
- 灰色是缺失或未匹配。 它既不是最低档,也不是零。
- 注意面积偏差。 大面积国家更显眼,不代表数据更可靠或更重要。
Example Data
这张图要的输入是:区域标识 + 连续数值,边界几何另外取。两边靠区域代码连接——必须用 ISO3 或底图自己的稳定代码,不能用国家名:USA、United States 与 United States of America 会静默错配。
economy_countries 提供 2023 年经济指标和 ISO3 代码;Natural Earth 只负责国界几何,不提供任何着色数值。
为什么先换算成相对中位数:人均 GDP 从约 251 美元跨到约 257,000 美元,直接在线性色阶上着色会让大多数国家挤在最浅的一小段。取 log2(本国人均 GDP / 全球有效经济体中位数) 之后,0 有明确含义——等于全球中位数,-1 是一半、+1 是两倍、±2 是四分之一和四倍。变换压掉了长尾,也让图例能直接写成倍数。这不是在声称全球中位数是一条经济学阈值,它只是一个稳定、可复算的视觉基准,所以标题和图例必须写明 "relative to median"。
library(dplyr)
library(sf)
library(rnaturalearth)
library(ggplot2)
library(scales)
library(biopalette)
# 公开地址,和数据集页上「下载 CSV」给的是同一个 —— 不写仓库相对路径:
# 那个目录不进仓库,读者 clone 下来也没有这个文件,这段代码就跑不了
economy <- read.csv(
"https://assets.evanzhou.org/tessera/csv/economy_countries.csv",
na.strings = c("", "NA")
)
valid_gdp <- economy |>
filter(!is.na(gdp_per_capita_usd), gdp_per_capita_usd > 0)
world_median <- median(valid_gdp$gdp_per_capita_usd)
economy_map_data <- economy |>
mutate(
relative_log2 = if_else(
!is.na(gdp_per_capita_usd) & gdp_per_capita_usd > 0,
log2(gdp_per_capita_usd / world_median),
NA_real_
)
) |>
select(iso3, country, gdp_per_capita_usd, relative_log2)
world_sf <- ne_countries(scale = "medium", returnclass = "sf") |>
# 少数条目的 iso_a3 是 -99(争议地区、属地),退回底图自己的 adm0_a3
mutate(
map_key = if_else(is.na(iso_a3) | iso_a3 == "-99", adm0_a3, iso_a3)
)
# 先检查再决定要不要修:当前版本全部有效。对跨日期变更线的经纬度多边形
# 盲目跑 st_make_valid(),反而可能生成横贯高纬度的带状伪影
stopifnot(all(st_is_valid(world_sf)))
# ISO3 对 ISO3,不用国家名
world_map <- world_sf |>
left_join(economy_map_data, by = c("map_key" = "iso3"))
# 属性表里有、底图里没有的代码必须显式列出来。join 不会报错,
# 那些小岛国和特殊经济体就这么无声地从图上消失了
unmatched <- setdiff(economy_map_data$iso3, world_sf$map_key)
if (length(unmatched)) {
message("Natural Earth 未匹配:", paste(unmatched, collapse = ", "))
}
Palettes
这里关心的是人均 GDP 从低到高的一条顺序,中位数只是方便解释倍数的基准,不代表两侧是性质相反的两类数据。所以用序列色板 mitonuclear_blue 而不是发散色板:近白蓝表示较低值,深蓝表示较高值。
地图是大面积填色,对明度顺序比散点图更敏感。相邻颜色必须保持稳定的浅到深关系,最浅色也要和缺失灰区分得开;国界只用很细的浅色线,避免边框压过填色。
缺失统一用一个不属于色板的中性灰,并在图注里明确说明——把 NA 放进色阶,读者就会把它当成最低档。
定性色板用于地图时不能假装连续插值,应该切成固定档位后逐档分配。
map_colors <- get_palette("mitonuclear_blue", type = "sequential")
# 中性灰,刻意不取自色板:它必须让人一眼看出"不属于这条色阶"
missing_color <- "#D7D6D0"
map_theme <- theme_void(base_size = 13) +
theme(
legend.position = "bottom",
legend.title = element_text(face = "bold", size = 11),
legend.text = element_text(size = 10),
plot.title = element_text(face = "bold", size = 16, hjust = 0),
plot.subtitle = element_text(color = "grey35", hjust = 0),
plot.caption = element_text(color = "grey45", hjust = 0),
plot.margin = margin(14, 14, 10, 14)
)
Recipe
| No. | Method | Input Data | Palettes |
|---|---|---|---|
| 1 | ggplot2 + sf |
world_map |
map_colors |
| 2 | ggplot2 + sf |
world_map_binned |
binned_palette |
1 · 连续渐进色阶
保留完整梯度。色阶限制固定为 -5 到 +5,也就是全球中位数的约 1/32 到 32 倍,极少数超出范围的值压到端点。
每个颜色位置是固定的,切换年份时不会因为样本极值变化而重新解释同一种颜色。
#| fig: continuous
#| fig-width: 10
#| fig-height: 6.3
ggplot(world_map) +
geom_sf(
aes(fill = relative_log2),
color = "white",
linewidth = 0.12 # 国界细到几乎只是分隔线,不和填色抢
) +
scale_fill_gradientn(
colors = map_colors,
limits = c(-5, 5),
breaks = c(-4, -2, 0, 2, 4),
labels = c("1/16×", "1/4×", "Median", "4×", "16×"), # 图例直接写倍数
oob = squish, # 超出 limits 的压到端点,而不是变成 NA 灰
na.value = missing_color,
name = "GDP per capita\nrelative to median"
) +
# 裁到南纬 60°:南极占大量版面又没有这个指标
coord_sf(xlim = c(-180, 180), ylim = c(-60, 85), expand = FALSE) +
labs(
title = "GDP per capita relative to the global median",
subtitle = paste0(
"2023 · log₂ ratio · median $",
comma(round(world_median))
),
caption = "Economic data: World Bank WDI · Boundaries: Natural Earth · Grey: unavailable"
) +
map_theme +
theme(legend.key.width = unit(1.55, "cm"))
