Code
library(sf)
library(terra)
library(tmaptools)
library(tmap)
library(htmlwidgets)
library(cols4all)
library(ggplot2)
library(ggspatial)
library(dplyr)
David O’Sullivan
November 16, 2024
I’ve just spent much of a not especially nice Saturday (weather-wise) tidying up some loose ends on the Computing Geographically website. In particular, I’ve been updating the R code snippets that make many of the figures. I started out intending to update the tmap
v3 code to v4, since the latter is now recommended by the developer. It has, for example, been adopted in the latest edition of the excellent Geocomputation with R.
A little way into the process, I realised that since doing last year’s 30 Day Map Challenge (see my efforts here) I use ggplot2
a lot more for my everyday mapping work than I do tmap
. That’s in spite of having taught tmap
for several years, a choice I made because its learning curve is less steep than ggplot2
’s. Anyway, long story short, migrating the code on my book website from tmap
v3 to v4 turned into more of a mixed bag: migrating some code to tmap
v4, and some to ggplot2
.
In this post I discuss some things to consider if you are choosing which of these two excellent packages—that’s important: they are both excellent packages—to use in various situations.
What the packages have in common is that they implement the idea of a grammar of graphics (hence ggplot2
’s name) where we progressively add layers to a graphic, specifying for each layer how data attributes are scaled to give values of :visual variables (colour, line width, line style, symbol size and so on). There’s a graphic (in German) showing this idea here.
So the packages are similar at a high-level. However, I’m not going to delve into details of the differences between these packages. For that, you will should explore the extensive online resources available. The best places to start are hard to pick. Maybe here for tmap
and here for geospatial stuff in ggplot2
. Just be aware that tmap
is undergoing a major upheaval as it transitions to v4 and while older code should still work, you’ll see a lot of warnings. Meanwhile, ggplot2
has a habit of changing things without preserving backwards compatibility, so it’s advisable to be wary of any code snippets more than 3 or 4 years old when you are looking for help.
Rather than the details, I want to explore the packages ‘in use’ by looking at four broad aspects of mapping that speak to the advantages of a package specifically designed for mapping (tmap
) over a more general purpose visualization tool (ggplot2
), which nevertheless holds it own. Those four things are choropleth maps, raster data, web maps, and ‘map junk’ (north arrows and the like).
tmap
’s killer appMaking ‘proper’ choropleth maps in ggplot2
is no fun at all. See my Day 13 experience in the 2023 30 Day Map Challenge. The difficulty is that the central tenet of classified choropleth mapping is controlling how you relate data to colour fills. It’s not that ggplot2
won’t allow you fine control over this aspect of your choropleth map, it’s just that it really, really wants you to map your data linearly, and continuously to a colour ramp. That’s a reasonable design decision for a general scientific visualization tool. It’s just not what cartographers do in choropleth mapping.
To illustrate, here’s an old dataset I often use: TB cases in Auckland City in 2006 by Census Area Unit:
And here’s the most basic ggplot2
choropleth map of the TB_RATE
variable (which is cases per 100,000 population):
aes(fill = ...)
specifies which variable maps to the colour fill
A few things stand out here (to my eye at least):
Of course we can fix all these things:
ggplot() +
geom_sf(data = ak, aes(fill = TB_RATE)) +
scale_fill_continuous_c4a_seq(
palette = "brewer.reds") +
coord_sf(datum = st_crs(ak))
scale_fill_continuous_c4a_seq()
is in the cols4all
package which includes a very wide range of palettes
st_crs()
and apply to the coordinate frame using coord_sf()
Note that I am using a cols4all
scale, because this is the preferred colour palettes package for tmap
, and provides a wide array of options. For casual mapping, we likely don’t care about the grid, and we can get rid of that too using theme_void()
:
ggplot() +
geom_sf(data = ak, aes(fill = TB_RATE)) +
scale_fill_continuous_c4a_seq(
palette = "brewer.reds") +
theme_void()
Something like the above, which is three lines of code after the introductory ggplot()
call, is what I use most of the time. And the map is good enough, especially if it’s temporary and a stepping stone on the way to some other analysis.
What does the same map look like in tmap
v4?
tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_continuous(
values = "brewer.reds")) +
tm_layout(
frame = FALSE,
legend.frame = FALSE)
Essentially the same map, up to minor aesthetic details. These are easily tweaked in either package, so we won’t worry about them too much here.
Where tmap
wins out is if you want to experiment with classic choropleth map classification schemes, for example:
m1 <- tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_intervals(values = "brewer.reds")) +
tm_layout(title = "Pretty", title.size = 0.8,
frame = FALSE, legend.frame = FALSE)
m2 <- tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_intervals(
values = "brewer.reds", style = "equal", n = 6)) +
tm_layout(
title = "Equal intervals", title.size = 0.8,
frame = FALSE, legend.frame = FALSE)
m3 <- tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_intervals(
values = "brewer.reds", style = "quantile", n = 6)) +
tm_layout(
title = "Quantiles", title.size = 0.8,
frame = FALSE, legend.frame = FALSE)
m4 <- tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_intervals(
values = "brewer.reds", style = "sd")) +
tm_layout(title = "Std. Dev.", title.size = 0.8,
frame = FALSE, legend.frame = FALSE)
tmap_arrange(m1, m2, m3, m4, ncol = 2)
"pretty"
, i.e. human-friendly round numbers
style = "equal"
to get equal intervals
style = "quantile"
for quantiles
style = "sd"
for standard deviation breaks
Forget the minor issue with aligning these maps exactly, the point here is that these are all classified choropleth maps, with the classification method specified by the style
parameter (more options are available than shown here). This is an established way to make choropleth colours easier to parse, or put another way, to make it easier to highlight the specific features of the data you want readers to focus on.
The magic here is that behind the scenes tmap
is using the classInt
package, and if we want to make similar maps in ggplot2
we have to do that work ourselves:
library(classInt)
class_breaks <- ak$TB_RATE |>
classIntervals(6, "quantile", digits = 1)
brks <- round(class_breaks$brks, 1)
ggplot() +
geom_sf(data = ak, aes(fill = TB_RATE)) +
scale_fill_binned_c4a_seq(
palette = "brewer.reds", breaks = brks) +
theme_void()
The above is the minimal ggplot2
version of a quantile map I can come up with. If you want the nice class labels then you have to make those labels yourself and do more work on the legend. You can see an example of this in my Day 13 map in the 2023 30 Day Map Challenge.
Overall, if you are making a lot of classified choropleth maps then tmap
is your friend.
tmap
’s other killer apptmap
is comfortable dealing with raster data. Here’s a simple example:
dem <- rast("dem.tif")
tm_shape(dem) +
tm_raster(
col = "dem",
col.scale = tm_scale_intervals(values = "hcl.terrain2"),
col.legend = tm_legend(title = "Elevation")) +
tm_layout(legend.frame = FALSE)
tm_raster()
‘announces’ that these data are raster so that some visual variables might be interpreted differently, e.g. col
is now what fill
is on a polygon layer, where col
is interpreted as linework or outline colour
Nice! Note that the same tm_scale_intervals()
function is used here to specify colours in this case as it was to specify fill colours in the choropleth map case. We can also have a continuous colour ramp, and layer on top a hillshade. First make the hillshade using some terra
functions:
Then just add it as another layer also using the col
aesthetic with a semi-transparent grey palette.
tm_shape(dem) +
tm_raster(
col = "dem",
col.scale = tm_scale_continuous(values = "hcl.terrain2"),
col.legend = tm_legend(title = "Elevation")) +
tm_shape(hillshade) +
tm_raster(
col = "hillshade",
col.scale = tm_scale_continuous(values = "brewer.greys"),
col_alpha = 0.35
) +
tm_layout(
legend.frame = FALSE, legend.show = FALSE)
tmap
is OK about adding a second layer using the col
visual variable
Nicer! If I have one issue here it’s with using the parameter name values
for the palette name, which takes a little bit of getting used to. Of course, the rationale for this is that the palette specifies where the colour visual variable is to get its values which in this context, are colours.
ggplot2
doesn’t really deal in rasters. We can make similar maps pretty easily, by converting to an x
, y
, attribute
dataframe:
dem_df <- dem |> as.data.frame(xy = TRUE)
ggplot(dem_df) +
geom_raster(aes(x = x, y = y, fill = dem)) +
scale_fill_continuous_c4a_seq(
palette = "hcl.terrain2", name = "Elevation") +
coord_sf(expand = FALSE) +
theme_void() +
theme(
panel.border = element_rect(fill = NA, linewidth = 1))
ggplot2
the colours are still a fill
here
expand = FALSE
prevents a margin around the data
ggplot2
’s theming system has a lot going on…
You can probably tell I’ve done a fair bit of this kind of thing: I didn’t just magic that expand = FALSE
option and the panel.border
thing out of nowhere.
Anyway, while this approach is fine for a small raster like this one, even here the x-y
dataframe equivalent to the raster has 35,000 or so rows, so this won’t scale well.
And we haven’t even got into the headaches involved if you want to layer something else on top of a raster dataset where you need to use the fill
aesthetic again, like say… a hillshade. Let’s just say the ggnewscale
package is your friend. I leave that as an exercise for the reader…
Again… I think it’s clear that tmap
is a better choice if you are making a lot of maps of raster data.
tmap
’s oth… OK, this is just getting sillyWhen it comes to web maps ggplot2
doesn’t even try. tmap
on the other hand has its ‘mode switch’ option. Set the mode to view
and you are making web maps!
tmap_mode("view")
tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_intervals(values = "brewer.reds"),
fill_alpha = 0.5)
tmap_mode("plot")
to switch back
So, yeah, if you are making bona fide web maps, tmap
every time. Switch back to plot
mode, and tmap
also does a good job of allowing you to use a web map as a basemap layer:
tmap_mode("plot")
tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_intervals(values = "brewer.reds"),
fill_alpha = 0.5) +
tm_basemap(server = "OpenStreetMap") +
tm_layout(frame = FALSE, legend.frame = FALSE)
ggplot2
can get the same effect using the ggspatial::annotation_maptile()
function (you will also need to have the prettymapr
package installed).
ggplot() +
annotation_map_tile(zoomin = 1) +
geom_sf(data = ak, aes(fill = TB_RATE), alpha = 0.5) +
scale_fill_continuous_c4a_seq(palette = "brewer.reds") +
theme_void()
annotation_map_tile()
is a function from ggspatial
Overall though, another win for tmap
.
As you might guess from the title of this section, I am not much concerned with north arrows and scalebars and such-like. tmap
has them built in.
tm_shape(ak) +
tm_polygons(
fill = "TB_RATE",
fill.scale = tm_scale_continuous(values = "brewer.reds")) +
tm_compass() +
tm_scalebar(position = c("left", "bottom")) +
tm_layout(legend.frame = FALSE)
To get the same things in ggplot2
you need the ggspatial
package, and they’re perfectly serviceable. I am about as excited about this as I sound.
ggplot() +
geom_sf(data = ak, aes(fill = TB_RATE)) +
scale_fill_continuous_c4a_seq(palette = "brewer.reds") +
annotation_north_arrow(location = "br") +
annotation_scale(location = "bl") +
theme_void() +
theme(panel.border = element_rect(fill = NA))
ggspatial
function
Needless to say, both tmap
and ggplot2
offer lots of flexibility for adding titles and subtitles and positioning all these elements around the map wherever you want them.
All in all, this one is probably a tie, assuming you’ve installed ggspatial
.
In brief then: tmap
is better at:
and about equal on
The latter two assuming that you have installed ggspatial
. The funny thing is, I still find myself using ggplot2
more! For me the counterpoints to the above are:
ggplot2::geom_tile
is often good enough for my purposes;tmap
to get an overview of my data, now I tend to use QGIS. If I really need a web map, I will certainly use tmap
; andIf your mix of use-cases is different, especially if choropleth maps, raster data, and web maps loom larger in your world than they do in mine, then you might wind up making a different choice.
I think the reason I end up gravitating to ggplot2
is that it is the entry point into a much wider visualization world. The idiom it has popularised, which tmap
has reworked for mapping, of layering a series of aesthetics each linking a data variable to a visual variable (that so-called grammar of graphics) is more cleanly implemented in ggplot2
, as you’d expect it to be. And the exact same semantics apply to scatterplots, boxplots, violin plots, histograms, bar charts, and so on. The ggplot2
ecosystem is sprawling and extremely powerful, and making maps is just one small corner of it.
Because of that, I am always making charts using ggplot(data) + geom_*()
and it feels very natural to make maps the same way.
Having said that, for R coded maps that cover all the mapping bases, you should probably also get to grips with tmap
. I use both regularly, even if I do use ggplot2
a little more regularly!