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

choropleth_map

Regions filled by value on a shared scale, so spatial clusters of high and low stand out.

示例数据
economy_countries
配色
mitonuclear_blue
语言
R
成图预览另有 1 张在配方里
choropleth_map

Introduction

分级填色地图适合看空间格局:高值或低值是否连成区域,相邻国家是否呈现共同模式。地图的价值只来自位置;要精确比较具体数值,排序柱状图通常更诚实。

它不适合表达总量。大国在画布上天然更抢眼,俄罗斯和新加坡即使数值相同,视觉重量也相差悬殊。人口、GDP 总量这类指标还会和国土面积产生额外联想,所以这里选人均 GDP,减少"面积大所以总量大"的误读。

读的时候按这个顺序:

  1. 先看成片区域。 地图用于识别空间聚集,不用于给国家排精确名次。
  2. 确认基准。 图例里的 Median 是全球有效经济体的中位数,不是零美元;它是读数刻度,不是色板的中性色。
  3. 看图例是连续还是分箱。 分箱边界是人为的,跨过边界不代表现实中突然发生跃迁。
  4. 灰色是缺失或未匹配。 它既不是最低档,也不是零。
  5. 注意面积偏差。 大面积国家更显眼,不代表数据更可靠或更重要。

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"))
choropleth_map — continuous
choropleth_map-continuous

2 · 固定倍数分箱

读者只需要稳定的高低档位时用这一版;色板本身是定性的时候也只能这么切。

五档边界固定为中位数的 1/4、约 0.7、约 1.4 和 4 倍——它们来自 log₂ 空间里的 -2、-0.5、0.5 和 2,不是每次重算的分位数。

#| fig: binned
#| fig-width: 10
#| fig-height: 6.3
bin_breaks <- c(-Inf, -2, -0.5, 0.5, 2, Inf)
bin_levels <- c("≤ 1/4×", "1/4–0.7×", "0.7–1.4×", "1.4–4×", "≥ 4×")

world_map_binned <- world_map |>
  mutate(
    relative_band = cut(
      relative_log2,
      breaks = bin_breaks,
      labels = bin_levels,
      include.lowest = TRUE,
      ordered_result = TRUE
    )
  )

# 从同一条色阶上等距取五色,两张图的方向才一致
binned_colors <- map_colors[
  round(seq(1, length(map_colors), length.out = length(bin_levels)))
]
binned_palette <- setNames(binned_colors, bin_levels)

ggplot(world_map_binned) +
  geom_sf(
    aes(fill = relative_band),
    color = "white",
    linewidth = 0.12
  ) +
  scale_fill_manual(
    values = binned_palette,
    breaks = bin_levels,
    drop = FALSE,          # 某一档这年正好没有国家,图例里也要留着位置
    na.value = missing_color,
    name = "GDP per capita\nrelative to median"
  ) +
  coord_sf(xlim = c(-180, 180), ylim = c(-60, 85), expand = FALSE) +
  labs(
    title = "GDP per capita in five fixed bands",
    subtitle = "2023 · thresholds stay comparable across datasets and years",
    caption = "Economic data: World Bank WDI · Boundaries: Natural Earth · Grey: unavailable"
  ) +
  map_theme +
  theme(legend.key.width = unit(1.15, "cm"))
choropleth_map — binned
choropleth_map-binned

Constraints

  • 面积不是数据。 大国在画布上天然更抢眼,视觉重量和数值无关。所以它不适合表达总量,也不适合排名。
  • 分箱边界是人为的。 跨过一条边界不代表现实中发生了跃迁。用分位数切箱更要小心:分位数只表示在这批样本里的相对排名,换个年份边界就移动了,不能写成绝对水平。
  • 跨年份比较要固定刻度。 中心、色阶上下限或倍数边界都得钉死,不能每年按各自的分位数重新切——否则同一种颜色在两张图上代表的不是同一件事。
  • 灰色是缺失,不是最低档也不是零。 而未匹配的区域和真正没有数据的区域在图上长得一样,所以匹配率必须在代码里显式检查。
  • 世界边界的顶点数量极大。 导成 SVG 或 PDF 会非常重,网页和常规报告用高分辨率 PNG/WebP 更合适;确实需要矢量就先简化几何。

两个 recipe 怎么比

  1. 连续数据、需要保留细微差别 → recipe 1。
  2. 定性色板、屏幕较小,或读者只需要稳定档位 → recipe 2。
  3. 需要精确排名 → 两个都别用,改画排序柱状图并标出有效样本量。
运行环境6 个包 · 2026-08-11T19:27:26.968+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
  • rnaturalearth 1.1.0
  • scales 1.4.0
  • sf 1.0.21