Introduction to Geospatial Data Visualisation with R

Author

Martin Schweinberger

Published

2026

Great Court, The University of Queensland

Introduction

This tutorial introduces static geospatial data visualisation in R: how to turn spatial information — country boundaries, fieldwork sites, dialect regions, speaker populations — into publication-quality maps that support linguistic argument. It focuses on the two most widely used tools in the modern R mapping ecosystem: the sf package (Pebesma 2018) for handling spatial data, and ggplot2 (Wickham 2016) for drawing maps, with a short comparative look at tmap (Tennekes 2018).

The emphasis throughout is practical and reproducible. Every example uses data that is either built into R or downloadable from CRAN packages, so you can run every chunk without hunting down shape files. The examples are drawn from language-related contexts — world-level country data, point locations of English corpora, European language distributions — but the techniques transfer directly to any spatial domain.

Interactive mapping with leaflet is deliberately out of scope here. If you need pan-and-zoom maps with popups and tooltips, see the LADAL tutorial on Interactive Visualizations in R, which covers leaflet and related tools in depth. This tutorial is about static, print-quality cartography.

NoteCitation

Martin Schweinberger. 2026. Introduction to Geospatial Data Visualisation with R. The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia. url: https://ladal.edu.au/tutorials/geospatial/geospatial.html (Version 3.1.1).

Preparation and session set up

This tutorial uses a small set of packages from CRAN. If you have not installed them yet, run the installation chunk below once. The rnaturalearthdata package carries the medium-resolution country boundaries used throughout the tutorial, so it needs to be installed alongside rnaturalearth.

Code
install.packages("sf")
install.packages("dplyr")
install.packages("tibble")
install.packages("ggplot2")
install.packages("ggspatial")
install.packages("ggrepel")
install.packages("rnaturalearth")
install.packages("rnaturalearthdata")
install.packages("tmap")
install.packages("scales")
install.packages("viridis")
install.packages("flextable")
install.packages("checkdown")

Once installed, load them:

Code
library(sf)
library(dplyr)
library(tibble)
library(ggplot2)
library(ggspatial)
library(ggrepel)
library(rnaturalearth)
library(rnaturalearthdata)
library(tmap)
library(scales)
library(viridis)
library(flextable)
library(checkdown)
TipLearning Objectives

By the end of this tutorial, you will be able to:

  • explain what a simple feature is and how sf represents spatial data in R
  • obtain world and regional map data using rnaturalearth and read shape files or GeoJSON from disk
  • draw a basic country map in ggplot2 using geom_sf()
  • create a choropleth map by joining attribute data to geometries
  • plot point data (such as fieldwork sites or corpus locations) on a base map, with labels that do not overlap
  • transform between coordinate reference systems and choose a projection appropriate to the map’s purpose
  • add cartographic furniture such as scale bars, north arrows, titles and legends with ggspatial
  • produce a thematic map in tmap and recognise where it is quicker than ggplot2
  • export maps at an appropriate resolution and format for publication
WarningPrerequisites

This tutorial assumes familiarity with:

No prior exposure to GIS software is required.


Spatial Data in R

NoteSection overview

This section introduces simple features — the standard way of representing spatial vector data in R — and the sf package that implements them. You will see what an sf object looks like, how its geometry column works, and why the design makes spatial data play well with the rest of the tidyverse.

Simple features

A simple feature is a geographic object with a geometry and (usually) some attribute data attached to it. The geometry is one of a small set of shapes: a point (a single location), a line (a sequence of connected points), a polygon (a closed shape), or a collection of these. Everything cartographic you will do in this tutorial is some combination of these shapes.

The sf package (Pebesma 2018) implements this model in R. An sf object is a regular data frame with one special column — the geometry column — that stores the geographic shape for each row. The other columns hold whatever attribute information travels with the shape: a country name, a population estimate, an ISO code, a measurement from fieldwork. Because the object inherits from data.frame, every tidyverse verb (filter(), mutate(), select(), left_join(), summarise()) works on it unchanged. The geometry comes along for the ride.

Load a world dataset at medium resolution from rnaturalearth and inspect it:

Code
world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

class(world)
[1] "sf"         "data.frame"

The class tells us this is both an sf object and a data.frame. Looking at the first few columns and rows:

Code
world |>
  dplyr::select(name_long, continent, pop_est, gdp_md) |>
  head(5)
Simple feature collection with 5 features and 4 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: -73.36621 ymin: -22.40205 xmax: 109.4449 ymax: 23.34521
Geodetic CRS:  WGS 84
  name_long     continent  pop_est gdp_md                       geometry
1  Zimbabwe        Africa 14645468  21440 MULTIPOLYGON (((31.28789 -2...
2    Zambia        Africa 17861030  23309 MULTIPOLYGON (((30.39609 -1...
3     Yemen          Asia 29161922  22581 MULTIPOLYGON (((53.08564 16...
4   Vietnam          Asia 96462106 261921 MULTIPOLYGON (((104.064 10....
5 Venezuela South America 28515829 482359 MULTIPOLYGON (((-60.82119 9...

Notice the final geometry column at the bottom of the print output: it holds a MULTIPOLYGON for each country, because countries are polygons (some, like Indonesia, consist of many disjoint polygons, hence MULTI). The polygons themselves are stored as sequences of coordinate pairs, but you rarely need to look at them directly — sf handles the geometry transparently as long as you use its functions.

Coordinate reference systems

Every sf object carries a coordinate reference system (CRS), which specifies how the numeric coordinates in the geometry column map onto locations on the Earth. Without a CRS, the numbers are meaningless. You can inspect the CRS with sf::st_crs():

Code
sf::st_crs(world)$input
[1] "WGS 84"

The output EPSG:4326 is the near-universal default for longitude/latitude data on the WGS84 ellipsoid — the system GPS devices use. We will come back to CRSs in detail in the section on projections. For now, the main thing to know is that if two sf objects have different CRSs, you must transform one of them before combining them.

Simple feature geometry types

The sf model supports a small, standardised set of geometry types. The three you will meet most often are:

  • POINT — a single location, used for things like fieldwork sites, cities, or speaker addresses
  • LINESTRING — a connected sequence of points, used for things like roads, rivers, or isogloss lines
  • POLYGON — a closed shape, used for countries, regions, or dialect areas

The MULTI variants (MULTIPOINT, MULTILINESTRING, MULTIPOLYGON) hold several geometries of the same type as a single feature. The United Kingdom, for example, is a MULTIPOLYGON because it includes Great Britain, Northern Ireland, and many smaller islands as separate polygons that belong to one country.

Q1. You load a shape file of Australian local government areas with sf::read_sf(). What makes the resulting object a simple feature rather than just a data frame?






Getting Map Data

NoteSection overview

This section covers how to obtain the geographic base layers your maps need. For most linguistic use cases, the rnaturalearth package will give you what you want with a single function call. Loading shape files or GeoJSON from disk is also a one-liner, and both are covered here.

rnaturalearth

The rnaturalearth package (South et al. 2024) bundles data from Natural Earth, a public-domain cartographic dataset used widely in professional publishing. For countries, use ne_countries():

Code
world <- rnaturalearth::ne_countries(scale = "medium", returnclass = "sf")

The scale argument controls level of detail. "small" gives you a tiny file good enough for world-scale thumbnails; "medium" is a sensible default for most publication maps; "large" is finer but requires the separately installable rnaturalearthhires package. The returnclass = "sf" argument is important — without it, ne_countries() returns an older spatial format that most modern code does not expect.

To pull a specific region, filter after loading rather than passing a region argument — the dataset’s continent, region_un, and subregion columns give you fine-grained control:

Code
europe <- world |>
  dplyr::filter(continent == "Europe",
                name_long != "Russia")

Dropping Russia from a European map is a common cartographic choice because its vast longitudinal extent otherwise forces you either to zoom out and lose detail elsewhere, or to clip the geometry.

Reading shape files and GeoJSON

If someone sends you a shape file (.shp) or a GeoJSON (.geojson), sf::read_sf() reads either into an sf object:

Code
# shape file (also reads the companion .dbf, .shx, .prj in the same folder)
dialect_areas <- sf::read_sf("data/dialect_areas.shp")

# GeoJSON
dialect_areas <- sf::read_sf("data/dialect_areas.geojson")

Shape files are a group of files sharing the same base name (.shp, .dbf, .shx, and ideally .prj), so they must all live in the same folder for the read to succeed. GeoJSON is a single text file and is easier to share.

Other sources

Beyond rnaturalearth, three sources come up frequently in linguistic research. The GADM project provides administrative boundaries at multiple levels (country, state, local government area) — useful for dialectology or regional sociolinguistics. OpenStreetMap data is accessible through the osmdata package when you need streets, buildings, or points of interest. For world language data specifically, Glottolog (Hammarström et al. 2022) provides language locations as point data that can be loaded from its downloadable CSVs.


Your First Map with ggplot2

NoteSection overview

This section covers the fundamental building block of mapping in the tidyverse: geom_sf(). You will see how to draw a country, a region, and a styled map, and how to zoom to an area of interest with coord_sf().

A minimal world map

The simplest possible map uses geom_sf() with no aesthetics at all:

Code
ggplot(data = world) +
  geom_sf() +
  theme_minimal()

geom_sf() figures out from the geometry type what to draw: polygons get a fill and an outline, lines get a stroke, points get a dot. You do not need to tell it which columns hold the coordinates — the geometry column handles that.

Styling the map

Fill and outline colour, line width, and transparency are set the same way as for any other ggplot2 geom:

Code
ggplot(data = world) +
  geom_sf(fill = "grey90", colour = "grey40", linewidth = 0.2) +
  theme_void()

theme_void() strips away the panel grid, axes, and background — a good default for stand-alone maps where latitude and longitude values would be visual clutter. Keep theme_minimal() or theme_bw() when the coordinate values matter, as they can for comparing locations precisely.

Zooming to a region

coord_sf() controls the mapped area. Pass longitude limits as xlim and latitude limits as ylim:

Code
ggplot(data = europe) +
  geom_sf(fill = "grey90", colour = "grey40", linewidth = 0.2) +
  coord_sf(xlim = c(-12, 35), ylim = c(35, 72)) +
  theme_minimal()

coord_sf() is projection-aware: if you later specify a CRS with crs = ..., it will reproject the data on the fly. This matters because the simple xlim/ylim clip is applied after projection, so you get a sensible rectangular window in your map’s own coordinate system rather than a distorted one.

Q2. You draw a map of Europe with ggplot(data = europe) + geom_sf() and want to crop the display to just Scandinavia. Which coord_sf() call is correct?






Choropleth Maps

NoteSection overview

A choropleth map colours regions according to a variable: GDP per country, speakers per million, dialect density per county. This section covers how to join an attribute to a geometry, how to pick a sensible colour scale for continuous and categorical variables, and the pitfalls that make a choropleth misleading.

Mapping a continuous variable

Many choropleth variables you will want to show are already present on the rnaturalearth dataset. Population estimate is a good example:

Code
ggplot(data = world) +
  geom_sf(aes(fill = pop_est), colour = "white", linewidth = 0.1) +
  scale_fill_viridis_c(
    name   = "Population",
    trans  = "log10",
    labels = scales::label_number(scale_cut = scales::cut_short_scale()),
    na.value = "grey90"
  ) +
  labs(title = "Estimated population by country") +
  theme_void() +
  theme(legend.position = "bottom")

Two things are worth noting. First, population is extremely skewed — China and India dwarf the rest — so a log transformation (trans = "log10") keeps the smaller countries visible rather than painting them all the same colour. Second, viridis is a perceptually uniform colour scale that works well for colourblind viewers and prints legibly in greyscale, which is why it is the modern default.

Joining your own data

More often, the variable you want to map is in a separate data frame and needs to be joined to the geometries. Suppose we have an inline table of English speaker populations (rough figures, for illustration):

Code
english_speakers <- tibble::tribble(
  ~iso_a2, ~en_speakers_mln,
  "US",    298,
  "GB",     60,
  "IN",    129,
  "NG",     79,
  "PK",     49,
  "PH",     64,
  "CA",     29,
  "AU",     19,
  "DE",     46,
  "FR",     23,
  "ZA",     16,
  "KE",      3,
  "IE",      5,
  "NZ",      4,
  "SG",      4,
  "JM",      3
)

Joining this to the geometries is a standard left_join():

Code
world_en <- world |>
  dplyr::left_join(english_speakers, by = "iso_a2")

Because world is an sf object and english_speakers is a plain tibble, the result is an sf object with a new column — the join preserves the geometry. Mapping the new variable follows the same pattern:

Code
ggplot(data = world_en) +
  geom_sf(aes(fill = en_speakers_mln), colour = "white", linewidth = 0.1) +
  scale_fill_viridis_c(
    name    = "English speakers\n(millions)",
    option  = "plasma",
    trans   = "sqrt",
    na.value = "grey90"
  ) +
  labs(title    = "Approximate English-speaking populations",
       caption  = "Figures are illustrative. Countries without a value are shown in grey.") +
  theme_void() +
  theme(legend.position = "bottom")

Grey for missing data is a standard cartographic choice because it reads as “no information” rather than “zero”. Treating missing values as zero on the colour scale is a common and misleading error.

Categorical fills

For a categorical variable — say, highlighting countries where English is a de facto main language against all others — the approach is the same but the scale is discrete:

Code
english_main <- c("US", "GB", "CA", "AU", "NZ", "IE",
                  "JM", "TT", "BB", "GY", "BZ", "BS", "AG",
                  "ZA", "ZW", "NA", "BW", "SZ", "LS", "MW",
                  "NG", "GH", "KE", "UG", "RW", "SL", "GM",
                  "SG", "PH", "FJ", "PG", "SB", "VU", "WS")

world_highlight <- world |>
  dplyr::mutate(
    english_status = dplyr::if_else(iso_a2 %in% english_main,
                                    "English as a main language",
                                    "Other")
  )

ggplot(data = world_highlight) +
  geom_sf(aes(fill = english_status), colour = "white", linewidth = 0.1) +
  scale_fill_manual(
    name   = NULL,
    values = c("English as a main language" = "#2b8cbe",
               "Other"                      = "grey85")
  ) +
  theme_void() +
  theme(legend.position = "bottom")

The scale_fill_manual() call pins each category to an explicit colour, which gives you full control and makes the map’s intent unambiguous.

What makes a choropleth misleading

Choropleths are easy to draw and easy to misread. The three most common problems are worth knowing. First, raw counts almost always just reproduce the population map — a map of absolute English speakers is dominated by the United States and India not because English is especially concentrated there, but because the countries are large. Normalising to a rate or a proportion (speakers per million, say) is usually what you want. Second, the colour scale shapes the story: equal-interval breaks can hide variation in a skewed distribution, and quantile breaks can exaggerate small differences. Pick breaks and transformations that match the variable. Third, polygon size has no relationship to the attribute: a huge sparsely populated country looks visually dominant whether its value is high or low. For variables where this matters, consider a cartogram or a dot-density approach instead (Monmonier 2018).

Q3. You want to compare the share of a country’s population that speaks English as a first language across countries. Which mapping choice is most likely to tell an honest visual story?






Point Maps

NoteSection overview

Point maps show individual locations: fieldwork sites, corpus collection cities, speaker addresses, language family centroids. This section covers how to turn a table of latitude/longitude into an sf object, plot the points on a base map, and label them without overlapping text.

From a data frame to an sf object

Many spatial datasets arrive as plain tables with a longitude column and a latitude column. The locations of the International Corpus of English (ICE) component collection cities make a good example:

Code
ice_sites <- tibble::tribble(
  ~variety,    ~city,          ~country,          ~lon,     ~lat,
  "ICE-GB",    "London",       "United Kingdom", -0.1278,  51.5074,
  "ICE-IRL",   "Dublin",       "Ireland",        -6.2603,  53.3498,
  "ICE-CAN",   "Vancouver",    "Canada",       -123.1207,  49.2827,
  "ICE-JAM",   "Kingston",     "Jamaica",       -76.7936,  17.9712,
  "ICE-NIG",   "Lagos",        "Nigeria",         3.3792,   6.5244,
  "ICE-EA",    "Nairobi",      "Kenya",          36.8219,  -1.2921,
  "ICE-IND",   "Mumbai",       "India",          72.8777,  19.0760,
  "ICE-SIN",   "Singapore",    "Singapore",     103.8198,   1.3521,
  "ICE-HK",    "Hong Kong",    "Hong Kong",     114.1694,  22.3193,
  "ICE-PHI",   "Manila",       "Philippines",   120.9842,  14.5995,
  "ICE-AUS",   "Sydney",       "Australia",     151.2093, -33.8688,
  "ICE-NZ",    "Christchurch", "New Zealand",   172.6362, -43.5321
)

To use this as spatial data, convert it with sf::st_as_sf(), telling it which columns hold the coordinates and in which CRS:

Code
ice_sf <- sf::st_as_sf(
  ice_sites,
  coords = c("lon", "lat"),
  crs    = 4326
)

coords = c("lon", "lat") lists longitude first, because longitude is the x-coordinate; this trips people up because many people say “lat-long” in speech. crs = 4326 is the EPSG code for WGS84 longitude/latitude, which is almost always correct for data that came out of a GPS, phone, or geocoding service.

Plotting points on a base map

Combining a polygon base map with points is a matter of layering two geom_sf() calls:

Code
ggplot() +
  geom_sf(data = world, fill = "grey95", colour = "grey70", linewidth = 0.2) +
  geom_sf(data = ice_sf, colour = "#d7301f", size = 2.5) +
  theme_void()

Draw the base layer first and the points second so the points sit on top.

Non-overlapping labels

Default text labels on a world map will collide. The ggrepel package provides geom_text_repel() and geom_label_repel(), which push labels off each other and off the points automatically. ggrepel does not read sf geometries directly, but you can keep the original lon and lat columns on your data and pass them in via aes():

Code
ggplot() +
  geom_sf(data = world, fill = "grey95", colour = "grey70", linewidth = 0.2) +
  geom_sf(data = ice_sf, colour = "#d7301f", size = 2.5) +
  ggrepel::geom_text_repel(
    data   = ice_sites,
    aes(x = lon, y = lat, label = city),
    size   = 3,
    colour = "grey20",
    min.segment.length = 0,
    box.padding        = 0.5,
    max.overlaps       = Inf
  ) +
  labs(title = "Cities of ICE English corpora") +
  theme_void()

min.segment.length = 0 ensures a leader line is drawn even for short displacements, which keeps the city–label pairing unambiguous. max.overlaps = Inf tells ggrepel not to silently drop labels when it cannot place them perfectly.

Sizing points by a variable

Point size is a good channel for an additional numeric variable, as long as the range is modest. If each ICE corpus is roughly a million words, that is uniform; a more interesting example might scale by estimated speakers of the local variety. Mapping size to a variable requires it to live in the sf object itself:

Code
ice_sf_sized <- ice_sf |>
  dplyr::mutate(speakers_mln = c(60, 5, 29, 3, 79, 3,
                                 129, 4, 7, 64, 19, 4))

ggplot() +
  geom_sf(data = world, fill = "grey95", colour = "grey70", linewidth = 0.2) +
  geom_sf(data = ice_sf_sized,
          aes(size = speakers_mln),
          colour = "#d7301f", alpha = 0.7) +
  scale_size_area(name = "L1 + L2 speakers\n(millions)",
                  max_size = 10) +
  theme_void() +
  theme(legend.position = "bottom")

scale_size_area() maps the variable to the area of the point, which is what the eye perceives — using scale_size() instead would scale the radius and exaggerate large values.

Q4. You have a tibble with columns city, lon, and lat and you want to turn it into an sf object of city locations in WGS84 longitude/latitude. Which call is correct?






Projections and Coordinate Reference Systems

NoteSection overview

A projection is the mathematical recipe for flattening the three-dimensional Earth onto a two-dimensional page. Every projection distorts something — area, shape, distance, or direction — and the right choice depends on what the map needs to show. This section covers how to set a projection in ggplot2, when to use common choices, and why the default web map projection is often the wrong one for research.

Why projection matters

If you plot longitude and latitude as plain x and y, you have made a projection choice — specifically, the equirectangular projection. It is the simplest possible projection and it stretches landmasses horizontally the further you get from the equator. Greenland, close to the North Pole, looks enormous; Africa, straddling the equator, looks relatively small. For a world map that is meant to communicate relative sizes of countries honestly, this is a poor choice (Monmonier 2018).

coord_sf() accepts a crs argument that reprojects the data on the fly. You can specify the CRS by its EPSG code (a short integer identifier) or by a PROJ string. The most useful CRS codes to know are:

Code

Name

Good for

EPSG:4326

WGS84 longitude/latitude

Default for raw lon/lat data; not a map projection

EPSG:3857

Web Mercator

Online tile maps; distorts areas badly

ESRI:54030

Robinson

World maps; good general-purpose compromise

ESRI:54009

Mollweide

World maps; equal-area, preserves relative sizes

EPSG:3035

ETRS89 / LAEA Europe

Europe-wide equal-area maps

EPSG:3577

GDA94 / Australian Albers

Australia-wide equal-area maps

EPSG:5070

NAD83 / Albers (US)

Contiguous US equal-area maps

Reprojecting a world map

The same world map in two different projections tells very different visual stories. Compare the default longitude/latitude plot with Robinson, a widely used compromise projection for world maps:

Code
ggplot(data = world) +
  geom_sf(fill = "grey90", colour = "grey40", linewidth = 0.2) +
  coord_sf(crs = "ESRI:54030") +
  labs(title = "World in Robinson projection") +
  theme_void()

And with Mollweide, an equal-area projection in which every country takes up a page area proportional to its real-world area:

Code
ggplot(data = world) +
  geom_sf(fill = "grey90", colour = "grey40", linewidth = 0.2) +
  coord_sf(crs = "ESRI:54009") +
  labs(title = "World in Mollweide (equal-area) projection") +
  theme_void()

If your map compares a quantity by area — population density, speaker density, language family extent — an equal-area projection like Mollweide is close to a requirement. Comparing densities across projections that distort area is cartographically dishonest.

Reprojecting a region

For a regional map, the right choice is usually a projection designed for that region. Europe looks very different in web Mercator (EPSG:3857) and in Lambert azimuthal equal-area for Europe (EPSG:3035):

Code
ggplot(data = europe) +
  geom_sf(fill = "grey90", colour = "grey40", linewidth = 0.2) +
  coord_sf(crs = "EPSG:3035",
           xlim = c(2.5e6, 7e6), ylim = c(1.5e6, 5.5e6)) +
  labs(title = "Europe in ETRS89 / LAEA Europe (EPSG:3035)") +
  theme_minimal()

Notice that xlim and ylim are now in metres rather than degrees, because EPSG:3035 is a projected CRS whose units are metres. coord_sf() handles the unit change automatically; the only thing you have to do is pass limits that make sense in the new units.

Explicitly reprojecting data

Sometimes you need the data itself to be in a different CRS — for example, when joining two spatial datasets that must share a CRS before sf will combine them. Use sf::st_transform():

Code
europe_laea <- sf::st_transform(europe, crs = "EPSG:3035")

sf::st_crs(europe_laea)$input
[1] "EPSG:3035"

The returned object is identical to europe except that every geometry has been reprojected into the new CRS. The attribute columns are untouched.

Q5. You want to publish a world map showing speaker-density per square kilometre for the world’s twenty most-spoken languages. Which projection is most appropriate?






Cartographic Furniture

NoteSection overview

A finished map needs more than geometry and colour: a title, a scale bar so readers can judge distance, a north arrow so they know which way is up, a well-placed legend, and sometimes a caption with the data source. This section uses the ggspatial package (Dunnington 2023) to add scale bars and north arrows to ggplot2 maps.

Scale bars and north arrows

ggspatial::annotation_scale() draws a scale bar; ggspatial::annotation_north_arrow() draws a north arrow. Both take a location argument ("bl", "br", "tl", "tr" for the four corners) and a few style arguments. A polished Australia map pulls them together:

Code
australia <- world |>
  dplyr::filter(name_long == "Australia")

aus_cities <- tibble::tribble(
  ~city,       ~lon,     ~lat,
  "Brisbane",  153.0260, -27.4705,
  "Sydney",    151.2093, -33.8688,
  "Melbourne", 144.9631, -37.8136,
  "Perth",     115.8605, -31.9505,
  "Adelaide",  138.6007, -34.9285,
  "Hobart",    147.3272, -42.8821,
  "Darwin",    130.8456, -12.4634,
  "Canberra",  149.1300, -35.2809
)
aus_cities_sf <- sf::st_as_sf(aus_cities, coords = c("lon", "lat"), crs = 4326)

ggplot() +
  geom_sf(data = australia, fill = "#f4e6d7", colour = "grey40", linewidth = 0.3) +
  geom_sf(data = aus_cities_sf, colour = "#4a1f6b", size = 2) +
  ggrepel::geom_text_repel(
    data = aus_cities,
    aes(x = lon, y = lat, label = city),
    size = 3.2, colour = "grey20",
    min.segment.length = 0, box.padding = 0.6
  ) +
  coord_sf(crs = "EPSG:3577",
           xlim = c(-2e6, 2.3e6), ylim = c(-5e6, -0.8e6)) +
  ggspatial::annotation_scale(
    location = "bl",
    width_hint = 0.3,
    style = "bar"
  ) +
  ggspatial::annotation_north_arrow(
    location = "tr",
    which_north = "true",
    style = ggspatial::north_arrow_fancy_orienteering,
    height = unit(1.5, "cm"),
    width  = unit(1.5, "cm")
  ) +
  labs(
    title    = "Capital cities of Australia",
    subtitle = "Projected in GDA94 / Australian Albers (EPSG:3577)",
    caption  = "Base map: Natural Earth"
  ) +
  theme_minimal() +
  theme(
    panel.background = element_rect(fill = "#eaf3f8", colour = NA),
    panel.grid       = element_line(colour = "grey85", linewidth = 0.2),
    plot.title       = element_text(face = "bold"),
    plot.subtitle    = element_text(colour = "grey30"),
    plot.caption     = element_text(colour = "grey40", size = 8)
  )

The projection — EPSG:3577, the Australian Albers equal-area — makes the continent sit squarely on the page without the horizontal stretch of unprojected longitude/latitude. The scale bar is in kilometres because the CRS unit is metres; annotation_scale() picks a round number of units automatically.

A few style choices are worth noting. which_north = "true" makes the north arrow point to true north rather than the top of the plot, which matters whenever the map is projected and the top of the page is not north. annotation_scale() with style = "bar" gives a two-tone bar that is easier to read than the ticked default. The caption names the data source — a professional habit worth cultivating (Brewer 2015).

Q6. On a map projected in Albers equal-area (EPSG:3577) where the units are metres, what does the scale bar drawn by ggspatial::annotation_scale() display, and why?






Thematic Mapping with tmap

NoteSection overview

tmap (Tennekes 2018) is an alternative mapping package built specifically for thematic cartography. Its grammar is similar in spirit to ggplot2 but tuned to cartographic conventions: default colour palettes, classed legends, and cleaner handling of multi-panel maps. This section introduces the basics and shows where it is faster to reach for than ggplot2.

The tmap grammar

A tmap map starts with tm_shape() naming the data, followed by one or more tm_* layers specifying what to draw with it:

Code
tmap::tmap_mode("plot")

tmap::tm_shape(europe) +
  tmap::tm_polygons(fill = "pop_est",
                    fill.scale = tm_scale_intervals(style = "quantile",
                                                    values = "viridis"),
                    fill.legend = tm_legend(title = "Population")) +
  tmap::tm_title("European countries by population estimate")

Three things to notice. First, tm_polygons() combines fill and outline in a single call — it is the common cartographic pattern of filled regions with thin boundaries. Second, tm_scale_intervals() picks classed breaks in your data (quantile, equal, pretty, jenks) rather than a continuous ramp; this reflects the cartographic preference for a small number of discrete classes, which readers can match to the legend without squinting. Third, tmap_mode("plot") sets static output; tmap_mode("view") would switch to an interactive leaflet map instead.

Faceting by a variable

Faceted maps — the same geometry drawn once per level of a grouping variable — are concise in tmap:

Code
europe_sub <- europe |>
  dplyr::filter(subregion %in% c("Northern Europe",
                                 "Western Europe",
                                 "Southern Europe",
                                 "Eastern Europe"))

tmap::tm_shape(europe_sub) +
  tmap::tm_polygons(fill = "pop_est",
                    fill.scale = tm_scale_intervals(style = "quantile",
                                                    values = "plasma"),
                    fill.legend = tm_legend(title = "Population")) +
  tmap::tm_facets(by = "subregion", nrow = 2)

Each panel is drawn to the same extent and the same colour scale, which is what you want for comparison. In ggplot2, achieving the same result requires more setup.

When to pick which

ggplot2 is the right default when the map is part of a larger figure made of ggplot2 layers, when you want fine control over every visual element, or when you are using projections and transformations that ggplot2’s coord_sf() makes natural. tmap is the right default when the map stands alone, when you want sensible cartographic defaults with minimal work, or when you want faceted thematic maps. The two coexist happily in a project — pick per map rather than per project.

Q7. You want to produce six small maps side-by-side, one per decade, showing a dialect feature’s spread. Each panel uses the same base geometry and the same colour scale. Which choice is likely to be fastest to write?






Exporting Publication-Quality Maps

NoteSection overview

A map that looks good on screen often looks bad in print — jagged edges, muddy colours, text too small to read. This section covers how to save a map at the right resolution, in the right format, and at the right physical size for a journal or thesis.

ggsave

For ggplot2 maps, ggsave() writes the last-drawn plot (or any plot object you pass to it) to a file. The arguments that matter most for print are width, height, units, and dpi:

Code
ggsave(
  filename = "australia_capitals.pdf",
  plot     = last_plot(),
  width    = 170,
  height   = 170,
  units    = "mm",
  dpi      = 300
)

ggsave(
  filename = "australia_capitals.png",
  plot     = last_plot(),
  width    = 170,
  height   = 170,
  units    = "mm",
  dpi      = 600
)

A width of 170 mm fits a two-column journal page across both columns; 85 mm fits a single column. Linguistics journals typically require 300 dpi for raster figures and prefer vector formats where possible. For tmap, use tmap::tmap_save() with the same width, height, and dpi arguments.

Vector vs raster

Two broad choices face you when exporting. Vector formats — PDF, SVG, EPS — store the map as a set of drawing instructions. They scale to any size without quality loss, so a map that is sharp at 85 mm will still be sharp if blown up to a conference poster. Prefer vector for anything that will be printed. Raster formats — PNG, TIFF, JPG — store the map as a grid of pixels. They are useful on the web, in slides, and when the map contains very many fine features (a dense scatter of points, a textured raster surface) that would make a vector file huge. PNG is the sensible default raster format; TIFF is required by some publishers; JPG is a poor choice for maps because its lossy compression blurs text and sharp edges.

Font sizes

A map that renders at 170 mm wide must have text that is readable at 170 mm — not at the 400 mm width RStudio shows you on screen. The rule of thumb: aim for 8 to 10 point text in the final figure. Set font sizes in ggplot2 with theme(...):

Code
theme(
  plot.title    = element_text(size = 11, face = "bold"),
  plot.subtitle = element_text(size = 10),
  plot.caption  = element_text(size = 8),
  legend.title  = element_text(size = 9),
  legend.text   = element_text(size = 8),
  axis.text     = element_text(size = 8)
)

Preview the saved file at 100% zoom before trusting that the map is legible. Previewing in RStudio’s zoomed plot pane is misleading.


Interactive Mapping

NoteSection overview

This section is a pointer, not a tutorial in itself. Interactive maps — pan, zoom, click, hover — are better served by a dedicated tutorial.

If you need a map that readers can pan, zoom, and interact with, the leaflet package is the standard tool. It integrates with HTML output formats, Quarto documents, R Markdown, and Shiny applications. A one-line conversion from a static tmap map to an interactive one is as simple as tmap::tmap_mode("view") before drawing. The LADAL tutorial on Interactive Visualizations in R covers leaflet in depth, including markers, popups, choropleth polygons, and tile layers.


Quick Reference

Essential functions

Function

Purpose

sf::st_as_sf()

Turn a data frame with lon/lat columns into an sf object

sf::st_read() / sf::read_sf()

Read a shape file or GeoJSON from disk

sf::st_write()

Write an sf object to a shape file or GeoJSON

sf::st_crs()

Inspect an object's coordinate reference system

sf::st_transform()

Reproject an sf object to a different CRS

rnaturalearth::ne_countries()

Get world or regional country boundaries

ggplot2::geom_sf()

Draw an sf object (points, lines, polygons)

ggplot2::coord_sf()

Set projection, limits, and axis labels on a ggplot map

ggspatial::annotation_scale()

Add a scale bar to a ggplot map

ggspatial::annotation_north_arrow()

Add a north arrow to a ggplot map

ggrepel::geom_text_repel()

Add non-overlapping text labels to a map

tmap::tm_shape() / tm_polygons()

Build a thematic map in tmap

tmap::tm_facets()

Draw one small-multiple panel per level of a grouping variable

ggplot2::ggsave() / tmap::tmap_save()

Save a map to PDF, PNG, SVG, or TIFF

A short projection decision guide

For a world map where honest areas matter (density, extent, distribution): use an equal-area projection such as Mollweide (ESRI:54009) or an Albers variant. For a world map used purely for navigation or orientation: Robinson (ESRI:54030) is a widely accepted compromise. For a continent-scale map: use a projection designed for that continent — EPSG:3035 for Europe, EPSG:3577 for Australia, EPSG:5070 for the contiguous US. For a city or sub-national map: use the local national grid if one exists. For a web tile map (and only then): web Mercator (EPSG:3857). Never use web Mercator for printed research maps.

Further reading

Citation and Session Info

NoteCitation

Martin Schweinberger. 2026. Introduction to Geospatial Data Visualisation with R. The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia. url: https://ladal.edu.au/tutorials/geospatial/geospatial.html (Version 3.1.1).

@manual{martinschweinberger2026introduction,
  author       = {Martin Schweinberger},
  title        = {Introduction to Geospatial Data Visualisation with R},
  year         = {2026},
  note         = {https://ladal.edu.au/tutorials/geospatial/geospatial.html},
  organization = {The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia},
  edition      = {3.1.1},
  doi          = {}
}
Code
sessionInfo()
R version 4.6.1 (2026-06-24 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 22631)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_Australia.utf8  LC_CTYPE=English_Australia.utf8   
[3] LC_MONETARY=English_Australia.utf8 LC_NUMERIC=C                      
[5] LC_TIME=English_Australia.utf8    

time zone: Australia/Brisbane
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] checkdown_0.0.13        flextable_0.10.3        viridis_0.6.5          
 [4] viridisLite_0.4.3       scales_1.4.0            tmap_4.4-1             
 [7] rnaturalearthdata_1.0.0 rnaturalearth_1.2.0     ggrepel_0.9.8          
[10] ggspatial_1.1.11        ggplot2_4.0.3           tibble_3.3.1           
[13] dplyr_1.2.1             sf_1.1-3               

loaded via a namespace (and not attached):
 [1] tidyselect_1.2.1        farver_2.1.2            S7_0.2.2               
 [4] fastmap_1.2.0           leaflegend_1.3.0        leaflet_2.2.3          
 [7] fontquiver_0.2.1        XML_3.99-0.25           digest_0.6.39          
[10] lifecycle_1.0.5         terra_1.9-50            magrittr_2.0.5         
[13] compiler_4.6.1          rlang_1.3.0             tools_4.6.1            
[16] yaml_2.3.12             data.table_1.18.6.1     knitr_1.52             
[19] labeling_0.4.3          askpass_1.2.1           htmlwidgets_1.6.4      
[22] sp_2.2-4                classInt_0.4-11         xml2_1.6.0             
[25] RColorBrewer_1.1-3      abind_1.4-8             KernSmooth_2.23-26     
[28] purrr_1.2.2             withr_3.0.3             leafsync_0.1.0         
[31] grid_4.6.1              gdtools_0.5.2           cols4all_0.10          
[34] e1071_1.7-17            leafem_0.2.5            colorspace_2.1-4       
[37] spacesXYZ_1.6-0         cli_3.6.6               rmarkdown_2.32         
[40] ragg_1.5.2              generics_0.1.4          otel_0.2.0             
[43] rstudioapi_0.19.0       commonmark_2.0.0        tmaptools_3.3          
[46] DBI_1.3.0               proxy_0.4-29            stars_0.7-3            
[49] parallel_4.6.1          s2_1.1.13               base64enc_0.1-6        
[52] vctrs_0.7.3             jsonlite_2.0.0          fontBitstreamVera_0.1.1
[55] litedown_0.11           patchwork_1.3.2         systemfonts_1.3.2      
[58] crosstalk_1.2.2         tidyr_1.3.2             units_1.0-1            
[61] maptiles_0.13.0         glue_1.8.1              lwgeom_0.2-17          
[64] codetools_0.2-20        gtable_0.3.6            raster_3.6-32          
[67] logger_0.4.3            pillar_1.11.1           htmltools_0.5.9        
[70] openssl_2.4.2           R6_2.6.1                textshaping_1.0.5      
[73] wk_0.9.5                evaluate_1.0.5          lattice_0.22-9         
[76] markdown_2.0            png_0.1-9               fontLiberation_0.1.0   
[79] class_7.3-23            zip_3.0.2               uuid_1.2-2             
[82] Rcpp_1.1.2              gridExtra_2.3.1         officer_0.7.7          
[85] xfun_0.62               pkgconfig_2.0.3        
NoteAI Transparency Statement

This tutorial was drafted with the assistance of Claude (claude.ai), a large language model created by Anthropic. Claude was used to draft the structure and prose across all sections, write the R code for every example (base maps, choropleth, point maps, projections, cartographic furniture, and tmap comparisons), compose the seven checkdown exercises with their right/wrong feedback, assemble the Quick Reference tables, and write the companion .bib file. The modern CRAN-only package stack (sf, ggplot2, rnaturalearth, rnaturalearthdata, ggspatial, ggrepel, tmap) was chosen specifically to replace the retired maptools, rgeos, and rgdal packages used in the previous version of this tutorial. All content was reviewed and approved by Martin Schweinberger, who takes full responsibility for its accuracy and pedagogical appropriateness.

Back to top

Back to HOME

References

Brewer, Cynthia A. 2015. Designing Better Maps: A Guide for GIS Users. 2nd ed. Esri Press.
Dunnington, Dewey. 2023. Ggspatial: Spatial Data Framework for Ggplot2. https://CRAN.R-project.org/package=ggspatial.
Hammarström, Harald, Robert Forkel, Martin Haspelmath, and Sebastian Bank. 2022. “Glottolog 4.6.” Max Planck Institute for Evolutionary Anthropology (Leipzig), ahead of print. https://doi.org/10.5281/zenodo.6578297.
Lovelace, Robin, Jakub Nowosad, and Jannes Muenchow. 2019. Geocomputation with R. Chapman; Hall/CRC. https://doi.org/10.1201/9780203730058.
Monmonier, Mark. 2018. How to Lie with Maps. 3rd ed. University of Chicago Press.
Pebesma, Edzer. 2018. “Simple Features for R: Standardized Support for Spatial Vector Data.” The R Journal 10 (1): 439–46. https://doi.org/10.32614/RJ-2018-009.
Pebesma, Edzer, and Roger Bivand. 2023. Spatial Data Science: With Applications in R. Chapman; Hall/CRC. https://doi.org/10.1201/9780429459016.
South, Andy, Michael Sumner, Philippe Massicotte, and Hannah Frick. 2024. Rnaturalearth: World Map Data from Natural Earth. https://CRAN.R-project.org/package=rnaturalearth.
Tennekes, Martijn. 2018. “Tmap: Thematic Maps in R.” Journal of Statistical Software 84 (6): 1–39. https://doi.org/10.18637/jss.v084.i06.
Wickham, Hadley. 2016. Ggplot2: Elegant Graphics for Data Analysis. 2nd ed. Springer. https://doi.org/10.1007/978-3-319-24277-4.