Session 3: Choropleths, Classification, and Interactive Maps
viridis rather than a rainbowleaflet, embedded in your Quarto reportOur variable today: intentional homicides per 100,000 inhabitants, by state and by municipio.
Code reads ../data/..., exactly as in Sessions 1 and 2:
The complete, ordered code for this session is in session3_code.R.
../data/mexico_homicides.gpkg — a GeoPackage with two layers:
| Layer | Rows | Contents |
|---|---|---|
states |
32 | boundaries, population, homicides, rate |
municipios |
2,469 | the same, at municipio level |
mxmaps packageCVEGEO codesA three-year mean, not a single year, because one bad year in a small municipio is noise, not signal.
[1] "municipios" "states"
cve_ent state_name pop homicides rate
1 01 Aguascalientes 1425607 76.66667 5.377826
2 02 Baja California 3769020 2389.66667 63.402865
3 03 Baja California Sur 798447 37.00000 4.633996
[1] 32
[1] "126,014,024"
[1] 26612
126 million people, about 26,600 homicides a year. Both match the published national figures.
Important
Always do this. A choropleth will happily draw nonsense. Checking that your totals reproduce a number you can look up elsewhere is the cheapest error-catching you will ever do.
A choropleth is a thematic map where regions are coloured according to a statistical variable.
Three decisions, and every one of them changes the story:
Most bad maps are bad because of decision 1 or decision 3.
Homicides come from SESNSP. Population comes from the census. Boundaries come from INEGI. Three sources, three files.
They are joined on CVEGEO — INEGI’s geostatistical code. 31050 is Mérida, Yucatán: 31 for the state, 050 for the municipio.
Warning
Never join Mexican data on place names. GADM calls the capital Distrito Federal; INEGI calls it Ciudad de México. There are two municipios called Guadalupe, and several spellings of Tláhuac. Codes do not have this problem.
It works. The north-west and the Pacific centre are visibly worse off than the south-east.
Now we make it readable.
| Type | Use when | Example |
|---|---|---|
| Sequential | low → high | homicide rate |
| Diverging | departure from a midpoint | change since 2015 |
| Qualitative | unordered categories | dominant party, land use |
Using a qualitative palette for ordered data — or a rainbow for anything — invents structure that is not in the data.
viridis Familyviridis Familyviridis Familyviridis Familyviridis Familyviridis Familyviridis?Warning
turbo is the exception — it is a rainbow, and it fails all three tests. Look back at the turbo map: the eye jumps to the green states, which are in the middle of the distribution. Do not use it for a policy map.
direction = -1 makes dark mean high. For a variable like homicide, dark-is-bad matches what readers expect — and it survives photocopying better.
_c, _b, or _d?| Function | Gives you | Use for |
|---|---|---|
scale_fill_viridis_c() |
continuous gradient | smooth variables |
scale_fill_viridis_b() |
binned steps | choropleths |
scale_fill_viridis_d() |
discrete categories | factors |
For choropleths you almost always want binned — the eye cannot read a continuous ramp accurately. Which raises the real question: where do the bins go?
A binned choropleth reduces a continuous variable to a handful of colours.
That reduction is a choice, and it is the single biggest lever on what the reader sees.
Never pick breaks before you have looked at the distribution.
Right-skewed, but not degenerate. Mean 23.7, standard deviation 21.7. Eleven states are below 10; four are above 50.
classInt: Four Ways to Cut[1] 1.7 19.6 37.5 55.5 73.4 91.3
[1] 1.7 7.9 12.3 19.2 42.7 91.3
[1] 1.7 9.6 21.1 49.0 63.4 91.3
Now let us see what each one does to the map.
[1.65,19.6] (19.6,37.5] (37.5,55.5] (55.5,73.4] (73.4,91.3]
21 3 4 3 1
Twenty-one of thirty-two states in the first class. Equal interval divides the range, and the range is stretched by Colima at 91.
It is honest about magnitude — the classes really are equally wide — but it wastes four of your five colours on eight states.
Use it when data are roughly uniform, or when round-number breaks matter more than resolution.
Six or seven states per class, so the whole map varies and you can read the middle of the distribution.
But look at the colour bar: the swatches are wildly unequal in width. The top class runs from 43 to 91; the bottom from 1.7 to 8.
Warning
Quantile maps make rank visible and magnitude invisible. A state at 44 and a state at 91 get the same colour, though one is twice as violent.
[1] -19.8 2.0 23.7 45.4 67.2 88.9 110.6
The first break is negative — but a homicide rate cannot be below zero.
Standard-deviation breaks assume a roughly symmetric distribution. Ours is right-skewed, so the scheme spends a class on values that cannot exist.
Use it for genuinely bell-shaped data, or for a diverging map around a meaningful centre — change since last year, or deviation from the national average.
Jenks minimises variance within classes and maximises it between them — it puts the cuts where the data already has gaps. Here: 11, 11, 6, 3, 1.
| Scheme | Good when | Fails when |
|---|---|---|
| Equal interval | data are uniform; breaks must be round numbers | outliers stretch the range |
| Quantile | you care about rank; want a map that varies | magnitude matters |
| Std. deviation | data are symmetric; diverging maps | data are skewed |
| Jenks | you want the data’s own structure | maps must be comparable |
Important
If you make several maps that must be compared — 2015 next to 2023, say — fix the breaks by hand and reuse them. Jenks chooses different cuts for every map, and the maps stop being comparable.
state_name homicides rate
1 Guanajuato 2679 43.4
2 Baja California 2390 63.4
3 México 2286 13.5
4 Michoacán 1880 39.6
5 Chihuahua 1835 49.0
state_name homicides rate
1 Colima 668 91.3
2 Baja California 2390 63.4
3 Zacatecas 941 58.0
4 Morelos 1126 57.1
5 Chihuahua 1835 49.0
By count, the State of México is third — 2,286 homicides a year.
By rate, it is 13.5 per 100,000, well below the national average. It has 17 million people.
Colima has 668 homicides — barely a quarter of México’s — and the worst rate in the country at 91.3.
Important
A count map of almost any human event is, mostly, a map of where the people are. Almost always, you want a rate.
#Step1: Jenks breaks for each variable separately
b1 <- classIntervals(sta$homicides, n = 5, style = "jenks")$brks
b2 <- classIntervals(sta$rate, n = 5, style = "jenks")$brks
#Step2: One map each, side by side with patchwork
g1 <- ggplot(sta) + geom_sf(aes(fill = homicides)) +
scale_fill_viridis_b(breaks = unique(b1), direction = -1) + ggtitle("Count")
g2 <- ggplot(sta) + geom_sf(aes(fill = rate)) +
scale_fill_viridis_b(breaks = unique(b2), direction = -1) + ggtitle("Per 100,000")
g1 | g2#Step1: Read the municipio layer -- 2,469 units instead of 32
mun <- st_read("../data/mexico_homicides.gpkg", layer = "municipios", quiet = TRUE)
#Step2: The five worst rates in the country
mun %>% st_drop_geometry() %>% arrange(desc(rate)) %>%
transmute(municipio_name, state_name, pop,
homicides = round(homicides, 1), rate = round(rate)) %>% head(5) municipio_name state_name pop homicides rate
1 Doctor Coss Nuevo León 1360 10.0 735
2 Oquitoa Sonora 496 1.7 336
3 Sitio de Xitlapehua Oaxaca 713 1.7 234
4 Santa María Mixtequilla Oaxaca 4690 9.3 199
5 General Enrique Estrada Zacatecas 6644 13.0 196
Doctor Coss, Nuevo León has the worst homicide rate in Mexico: 735 per 100,000 — eight times Colima.
It has 1,360 inhabitants and averaged 10 homicides a year. One more incident moves the rate by 74 points.
Warning
A rate with a tiny denominator is unstable, not extreme. Ranking municipios by raw rate puts the smallest ones on top almost every time — and a map of it is a map of small populations.
The map on the next slide does the simplest of these: it shows municipios, but greys out those under 10,000 people.
#Step1: Flag the units we do not trust
mun <- mun %>% mutate(rate_shown = ifelse(pop < 10000, NA, rate))
#Step2: na.value gives the suppressed units their own colour
ggplot(mun) +
geom_sf(aes(fill = rate_shown), colour = NA) +
scale_fill_viridis_b(breaks = c(0, 10, 25, 50, 100), direction = -1,
na.value = "grey85", name = "per 100,000") +
coord_sf(crs = 6372) + theme_void()brks <- classIntervals(sta$rate, n = 5,
style = "jenks")$brks
ggplot(sta) +
geom_sf(aes(fill = rate),
colour = "white",
linewidth = 0.15) +
scale_fill_viridis_b(
breaks = unique(brks),
direction = -1,
name = "per 100,000") +
coord_sf(crs = 6372) +
labs(
title = "Homicide is concentrated in the north-west and the Pacific centre",
subtitle = "Intentional homicides per 100,000, annual mean 2021-2023",
caption = "Sources: SESNSP; INEGI Censo 2020") +
theme_void(base_size = 9)per 100,000, not ratetheme_void() — latitude and longitude gridlines are chart junk on a thematic mapcoord_sf(crs = 6372) — never publish a Mexican map in Web MercatorleafletA static map of 32 states cannot label them all, and a map of 2,469 municipios certainly cannot.
An interactive one does not have to — the reader hovers over their own state and reads the number.
Warning
leaflet needs unprojected coordinates. It expects EPSG:4326 and reprojects to Web Mercator itself. Hand it EPSG:6372 and you get an empty map.
leaflet MapThree lines and you have a pannable, zoomable map. Now we make it say something.
#Step1: A palette function built from our jenks breaks
pal <- colorBin("viridis", domain = sta_ll$rate,
bins = unique(brks), reverse = TRUE)
#Step2: An HTML label per state
labels <- sprintf("<strong>%s</strong><br/>%.1f per 100,000<br/>%s homicides/year",
sta_ll$state_name, sta_ll$rate,
format(round(sta_ll$homicides), big.mark = ",")) %>%
lapply(htmltools::HTML)
#Step3: Put it together. The ~ means "look this column up in the data".
leaflet(sta_ll) %>%
addTiles() %>%
addPolygons(fillColor = ~pal(rate), fillOpacity = 0.8,
color = "white", weight = 1,
highlightOptions = highlightOptions(weight = 3, color = "#333"),
label = labels) %>%
addLegend(pal = pal, values = ~rate,
title = "per 100,000", position = "bottomright")Hover over a state. That is the whole point.
A leaflet map is JavaScript. In a Quarto HTML document it depends on external files — unless you say otherwise:
Important
This is the trap from Session 1, and this is the week it bites. Without embed-resources: true, your socio formador opens the file and sees a blank white rectangle.
ggplot or leaflet?ggplot
leaflet
Tip
Put both in your reto report: a static map for the printed summary, an interactive one for the people who want to find their own municipio.
CVEGEO, never on place namesviridis is perceptually uniform, colourblind-safe, and prints in grey — turbo is notNext session: spatial joins — doing this by location rather than by code — plus buffers and intersections.
Popescu Data Science II — Session 3: Choropleths and Interactive Maps