--- title: "Honest maps: classification, missingness and distortion" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Honest maps: classification, missingness and distortion} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE, fig.width = 7, fig.height = 3.6, fig.align = "center", dpi = 96 ) library(countryatlas) library(ggplot2) snap <- world_snapshot$countries has_maps <- requireNamespace("maps", quietly = TRUE) has_sf <- requireNamespace("sf", quietly = TRUE) && requireNamespace("rnaturalearth", quietly = TRUE) && requireNamespace("rnaturalearthdata", quietly = TRUE) ``` ```{r setup-data, eval = has_maps} mapdf <- attach_geometry(snap, geometry = "polygon") ``` A world choropleth makes four claims before it says anything about your data: that the classes it drew are the natural ones, that grey means nothing rather than zero, that a rate over eleven thousand people is worth as much attention as one over a billion, and that the shapes on screen are the shapes on the ground. All four are usually false. This vignette is the tour of what `countryatlas` gives you for each. ## 1. The classification is doing the talking Brewer & Pickle (2002) ran 56 subjects over nine series of mortality maps and found **quantiles** among the most accurately read classifications, with natural breaks (Jenks) below 70% as accurate. That is the reverse of the common GIS default, and it matters because the choice is not cosmetic: it decides what the reader concludes. `classify_compare()` draws the same data under several methods at once. ```{r classify, eval = has_maps, fig.height = 3.2, fig.alt = "GDP per capita under quantile, Jenks, equal-interval and pretty breaks."} cmp <- classify_compare(mapdf, gdp_per_capita, ncol = 2) cmp ``` The picture is only half of it. The counts are attached to the plot: ```{r classify-report, eval = has_maps} attr(cmp, "countryatlas_classification") ``` Equal-interval and pretty breaks put over 90% of countries into a single class, because GDP per capita is strongly right-skewed and the top of the range is one country. A map like that is technically correct and communicates nothing. Quantiles put roughly 38 countries in each class. You do not need the comparison to get the report: any *classified* `world_map()` will produce it. (A continuous colourbar has no classes, so asking there returns nothing and says why.) ```{r one-report, eval = has_maps} p <- world_map(mapdf, gdp_per_capita, style = "quantile", classification_report = TRUE) attr(p, "countryatlas_classification") ``` Jenks still earns its place: on a strongly clustered distribution, quantiles will split a natural group across two colours where Jenks keeps it together. The point is to look, not to take the default. ## 2. Grey is not a value The default no-data grey reads as "low" to a lot of readers, which is precisely the wrong inference. `na_style` gives you three alternatives, and `"hatched"` (via the optional `ggpattern`) is the one that survives both colour-blindness and a black-and-white printer. ```{r na-style, eval = has_maps, fig.alt = "World choropleth with missing countries drawn in diagonal hatching."} world_map(mapdf, co2_per_capita, style = "quantile", na_style = "hatched", footnote = "auto") ``` `footnote = "auto"` writes the coverage line into the caption, so the map cannot quietly overstate what it covers. When the missingness *is* the story, map it directly: ```{r coverage, eval = has_maps, fig.alt = "Map of which countries report CO2 per capita."} coverage_map(mapdf, co2_per_capita) ``` `audit_coverage()` is the same question as a table, and is the better tool when you want to act on the answer rather than look at it. ## 3. Small denominators shout Any per-capita or per-100k figure computed over a tiny population is mostly noise, and on a choropleth it gets exactly as much ink as a figure computed over a billion people. There are two answers. The cartogram distorts geometry until area matches the denominator; **value-by-alpha** (Roth, Woodruff & Johnson 2010) leaves the geometry alone and spends *opacity* instead. ```{r vba, eval = has_maps, fig.alt = "Value-by-alpha map: GDP per capita in colour, population as opacity, over a dark background."} value_by_alpha_map(mapdf, gdp_per_capita, population) ``` Countries fade toward the background in proportion to how little population stands behind their number. Compare with the cartogram answer to the same problem, `cartogram_map()` / `dorling_map()`, in *Beyond the choropleth*: the trade-off is that a cartogram makes the weighting unmissable but costs you the recognisable world. ## 4. The projection is doing the talking too Every flat world map distorts something. `projection_info()` says what each of the thirteen preserves: ```{r proj-info} projection_info()[, c("projection", "property", "equal_area", "conformal")] ``` For a choropleth the honest choice is **equal-area**, because the eye reads coloured area as quantity, and a projection that inflates Greenland makes Greenland's value look more important than it is. Equal Earth is the package default and the recommendation (Šavrič, Patterson & Jenny 2019). ```{r equal-area} subset(projection_info(), equal_area)$projection ``` Tissot's indicatrix makes the cost visible. Each circle has the same radius on the ground; whatever the projection does to them, it is doing to your data. ```{r tissot-merc, eval = has_sf, fig.height = 4, fig.alt = "Tissot indicatrices on Mercator: circles stay circular but grow enormously toward the poles."} tissot_map("mercator") ``` ```{r tissot-ee, eval = has_sf, fig.alt = "Tissot indicatrices on Equal Earth: ellipses shear but hold constant area."} tissot_map("equal_earth") ``` Mercator keeps every circle round (it is conformal, so local shapes are right) and grows them without limit toward the poles. Equal Earth keeps every circle's *area* and shears the shapes instead. Neither is wrong; they are answers to different questions, and only one of them belongs under a choropleth. To see it on your own data, vary the CRS and hold everything else fixed: ```{r proj-compare, eval = has_sf, fig.height = 4.2, fig.alt = "One choropleth drawn under four projections."} attach_geometry(snap, geometry = "sf") |> projection_compare(gdp_per_capita, style = "quantile", labeller = "property") ``` ## 5. Say what the map is Everything above is a decision, and a published map should carry its decisions. `map_provenance()` reads them back off the plot. ```{r provenance, eval = has_maps, message = TRUE} world_map(mapdf, gdp_per_capita, style = "quantile", n_bins = 5, na_style = "hatched", footnote = "auto") |> map_provenance() ``` Every field there was already known when the plot was built; the only new thing is that you can read it. Paired with `footnote = "auto"` on the plot itself and the classification report, that is most of a methods note. Finally, `citation("countryatlas")` produces the package citation *and* the sources it reconciles: `countrycode`, the World Bank, Natural Earth, and the papers behind the methods used here. Citing the join layer without the data would be the last dishonest thing a map could do. ## 6. Where the data comes from, and when Two more ways a country map goes quietly wrong, both added in 3.0.0. **The borders are not the borders.** A 1950 map drawn on 2024 boundaries is simply a different world. `attach_geometry(year = )` and [historical_geometry()] draw the real ones, from CShapes -- including the colonies, without which most of Africa and Asia is absent: ```{r hist, eval = requireNamespace("cshapes", quietly = TRUE) && has_sf, fig.alt = "Choropleth drawn on 1950 borders including colonies and dependencies."} attach_geometry(snap[, c("iso3c", "gdp_per_capita")], year = 1950) |> world_map(gdp_per_capita, style = "quantile", title = "1950 borders, 1950 world") ``` Note what this costs: ISO 3166 was published in 1974 and never covered colonies, so historical geometry is keyed on Gleditsch-Ward codes and `iso3c` is `NA` for every entity that never had one. `country_join(key = "gwn")` is the join that works before 1970. **Membership is a function of time.** A snapshot silently misstates any panel that spans an accession: ```{r asof} c(`2016` = in_group("United Kingdom", "EU", as_of = 2016), `2021` = in_group("United Kingdom", "EU", as_of = 2021)) ``` ## 7. Islands are not missing at random `morans_i()`'s default weights are land-border contiguity, and an island has no land border. On the bundled snapshot that silently removes a quarter of the countries with data -- Japan, Australia, Madagascar, New Zealand, the Philippines, Cuba, Sri Lanka, Iceland and every small island state. They are not a random quarter. Not every island goes, either: the United Kingdom keeps its land border with Ireland, and Indonesia keeps its borders with Malaysia, Papua New Guinea and Timor-Leste. Which is rather the point -- you cannot tell from the finished map who dropped out of the statistic. ```{r weights, eval = has_sf} rbind( contiguity = morans_i(snap, gdp_per_capita, n_perm = 0)[c("i", "n", "n_excluded")], knn = morans_i(snap, gdp_per_capita, n_perm = 0, weights = country_weights("knn", k = 5))[c("i", "n", "n_excluded")] ) ``` Both numbers are defensible; only one of them is global. `country_weights()` also takes `"distance"`, and `"custom"` -- which is how an adjacency that is not geographic at all (trade volume, migration, shared language) goes through the same API. ## References Brewer, C. A. & Pickle, L. (2002). Evaluation of methods for classifying epidemiological data on choropleth maps in series. *Annals of the Association of American Geographers* 92(4), 662–681. Roth, R. E., Woodruff, A. W. & Johnson, Z. F. (2010). Value-by-alpha maps: an alternative technique to the cartogram. *The Cartographic Journal* 47(2), 130–140. Šavrič, B., Patterson, T. & Jenny, B. (2019). The Equal Earth map projection. *International Journal of Geographical Information Science* 33(3), 454–465.