Mapping

GESIS Workshop: Introduction to Geospatial Techniques for Social Scientists in R

Stefan Jünger, Anne-Kathrin Stroppe, Dennis Abel

2026-04-23

Now

Day Time Title
April 09 10:00-11:30 Introduction
April 09 11:30-11:45 Coffee Break
April 09 11:45-13:00 Data Formats
April 09 13:00-14:00 Lunch Break
April 09 14:00-15:30 Mapping
April 09 15:30-15:45 Coffee Break
April 09 15:45-17:00 Spatial Wrangling
April 10 09:00-10:30 Spatial Wrangling
April 10 10:30-10:45 Coffee Break
April 10 10:45-12:00 Applied Spatial Linking
April 10 12:00-13:00 Lunch Break
April 10 13:00-14:30 Spatial Analysis
April 10 14:30-14:45 Coffee Break
April 10 14:45-16:00 Spatial Econometrics & Outlook

Fun with flags… MAPS!

Fun with Flags by Dr. Sheldon Cooper. Big Bang Theory

Fun with maps

plot() does not allow us to manipulate the maps easily. But we already have the two most essential ingredients to create a nice map:

  1. Vector data stored in the ./data folder
  2. Some (hopefully) interesting attributes linked with the geometries

What makes a good map?

Good Mapping

  • Reduction to most important information
  • Legends, scales, descriptions
  • Audience oriented
  • Adjusted for color vision deficiencies

Bad Mapping

  • Overcrowding and overlapping
  • Unreadable information
  • Missing information like the legend or source
  • Poor choice of color palettes

What makes a good map?

… but there is one other type:

The fast but nice map.

  • Fast exploration of spatial data by visualizing the geometries and attributes
  • Might not be publication-ready yet, but they are more rewarding than just plotting information

Our approach: ggplot2

R offers several packages for mapping spatial data:

  • Base R graphics package: mapdata
  • Mobile-friendly interactive maps: leaflet
  • Interactive and static thematic maps: tmap, mapview

Today, we concentrate on ggplot2:

  • Part of the tidyverse — draws on knowledge many of you already have
  • Maximum flexibility for customizing every element
  • Seamlessly combines maps with other plots in one figure
  • Integrates with many extension packages (tidyterra, ggspatial, …)

What is ggplot2?

ggplot2 is well-known for creating plots. Thanks to sf and terra, we can exploit all amazing ggplot2 functions for spatial data.

In general, on ggplot2:

  • Well-suited for multi-dimensional data
  • Expects data (frames) as input
  • Components of the plot are added as layers
plot_call +
  layer_1 +
  layer_2 +
  ... +
  layer_n

Components of a Plot

According to Wickham (2010, p. 81), a layered plot consists of the following components:

  • Data and aesthetic mappings,
  • Geometric objects,
  • Scales,
  • (and facet specification)
plot_call +
  data +
  aesthetics +
  geometries +
  scales +
  facets

Cologne data

# cologne districts with attributes
attributes_cologne <- sf::read_sf("./data/attributes_cologne.shp")

# electric vehicle charging points
charger_cologne <- sf::read_sf("./data/charger_cologne.shp")

# raster: share of immigrants per grid cell
immigrants_cologne <- terra::rast("./data/immigrants_cologne.tif")

The attribute table

A quick look at what is in our data before we start mapping.

attributes_cologne |>
  sf::st_drop_geometry() |>       # drop geometry for a clean table view
  dplyr::select(id, ecar, cdu, spd, greens, afd, left, fdp) |>
  head(5)
# A tibble: 5 × 8
     id  ecar   cdu   spd greens   afd  left   fdp
  <dbl> <dbl> <dbl> <dbl>  <dbl> <dbl> <dbl> <dbl>
1   202   1.6  32.6  12.1   26.7   5.5   2.9  12.9
2   201   1    19    14.7   37.8   4.3   5.2   8.5
3   209   0.9  27.8  18.6   30.8   4.9   3.1   7.1
4   207   1.7  47.8   4.3   14.5   8.9   1    18.9
5   213   0.3  26.1  21.8   18.6  11.1   4.7   6.1

The columns hold, e.g., the share of electric cars (ecar) and EU election 2019 vote shares per Cologne district.

Here’s a first basic map

# a simple first map
ggplot() +
  geom_sf(data = attributes_cologne)

Making a plan

This map will be our canvas for the session. We will cover five building blocks:

  • THE MAP: adding attributes, choosing colors/palettes, adding layers
  • THE LEGEND: position, sizes, display
  • THE ENVIRONMENT: choosing from themes and building your own
  • THE META-INFORMATION: titles and sources
  • THE EXTRAS: scales and compass

If you are working on your maps, the ggplot2 cheatsheets will help you with an overview of scales, themes, labels, facets, and more.

THE MAP: a basis

# easy fill with a fixed color
ggplot() +
  geom_sf(
    data = attributes_cologne,
    fill = "steelblue",
    color = "white"
  )

THE MAP: add the aesthetics

We’ll concentrate on mapping the share of electric cars per district.

ggplot() +
  geom_sf(
    data = attributes_cologne,
    # map the attribute to fill color
    aes(fill = ecar)
  ) +
  # choose a continuous color scale
  scale_fill_continuous()

THE MAP: color palette

Are you having trouble choosing the right color? Some excellent tutorials exist, e.g. by Michael Toth.

ggplot() +
  geom_sf(
    data = attributes_cologne,
    aes(fill = ecar)
  ) +
  # readable with color vision deficiencies
  scale_fill_viridis_c(option = "plasma")

THE MAP: fine-tuning

ggplot() +
  geom_sf(
    data = attributes_cologne,
    aes(fill = ecar),
    # remove district borders
    color = NA
  ) +
  scale_fill_viridis_c(
    option = "plasma",
    # flip the scale direction
    direction = -1
  )

Save and reuse

Maps produced with ggplot2 are standard R objects (they are lists). We can assign them to reuse, plot later, and keep adding layers.

cologne_map <-
  ggplot() +
  geom_sf(
    data = attributes_cologne,
    aes(fill = ecar),
    color = NA
  )

cologne_map_better <-
  cologne_map +
  scale_fill_viridis_c(
    option = "plasma",
    direction = -1
  )

Save and reuse

Furthermore, ggsave() automatically detects the file format from the extension. You can also control height, width, and dpi — particularly useful for publication-ready graphics.

ggsave("cologne_map_better.png", cologne_map_better, dpi = 300)

THE MAP: point layers

Point data loaded with sf::read_sf() is displayed with the same geom_sf()ggplot2 detects the geometry type automatically.

Key aesthetics for point layers:

Argument What it controls Fixed or mapped?
color point color both
size point diameter both
alpha transparency (0–1) both
shape point shape (0–25) both

Show some points

ggplot() +
  geom_sf(
    data  = charger_cologne,
    color = "black",
    size  = 2,
    alpha = .6
  )

THE MAP: add a point layer

geom_sf() stacks layers in order — add the point layer after the polygon layer so it appears on top.

ggplot() +
  # 1st layer: polygon — e-car share
  geom_sf(
    data = attributes_cologne,
    aes(fill = ecar),
    color = NA
  ) +
  scale_fill_viridis_c(
    option = "plasma",
    direction = -1
  ) +
  # 2nd layer: points  
  geom_sf(
    data = charger_cologne,
    # wrap color in aes(): force a legend entry
    aes(color = "Charging Stations"),
    size = 2
  ) +
  scale_color_manual(
    name   = NULL,
    values = c("Charging Stations" = "black")
  )

THE LEGEND: dealing with the legend

You can handle everything concerning the legend within the scale_*.

ggplot() +
  geom_sf(
    data = attributes_cologne,
    aes(fill = ecar),
    color = NA
  ) +
  scale_fill_viridis_c(
    option = "plasma",
    direction = -1,
    # add a legend title
    name = "E-Car Share",
    # adjust legend display
    guide = guide_legend(
      # turn it horizontal
      direction = "horizontal",
      # put labels under the legend bar
      label.position = "bottom"
    )
  )

# check the help file for more options: ?guide_legend

THE ENVIRONMENT: get rid of everything?!

The theme controls all non-data displays. Instead of removing everything, try the built-in themes.

# use the cologne_map object as base
cologne_map +
  # remove all non-data ink
  theme_void()


# ... or try another built-in theme:
# theme_bw()
# theme_gray()
# theme_light()

# see all options: ?theme

THE ENVIRONMENT: build your own theme

cologne_map +
  theme_void() +
  theme(
    # bold all text elements
    title = element_text(face = "bold"),
    # move legend to bottom
    legend.position = "bottom",
    # change background color
    panel.background =
      element_rect(fill = "lightgrey")
  )

THE META-INFORMATION: adding labs

Always include and cite your data sources. labs() lets you add titles, subtitles, and captions directly to the map.

cologne_map +
  theme_void() +
  labs(
    title    = "Electric Cars in Cologne",
    subtitle = "Share of registered electric vehicles per district",
    caption  = "© Stadt Köln; Bundesnetzagentur"
  )

Exercise 3_1: Basic Maps

Exercise

To be continued…

Our code has already grown. Without going into too much detail, the following slides showcase some more changes you can make to your maps.

A map is never finished until you decide not to work on it anymore.

Facet maps: data preparation

facet_wrap() creates small multiples — one panel per group. The data must be in long format first.

# pivot the six party columns into one long column
cologne_parties <-
  attributes_cologne |>
  tidyr::pivot_longer(
    cols      = c(cdu, spd, greens, afd, left, fdp),
    # new column holding the party name
    names_to  = "party",
    # new column holding the vote share value
    values_to = "vote_share"
  )

# each district now appears six times — once per party
cologne_parties |>
  sf::st_drop_geometry() |>
  dplyr::select(id, party, vote_share) |>
  head(8)
# A tibble: 8 × 3
     id party  vote_share
  <dbl> <chr>       <dbl>
1   202 cdu          32.6
2   202 spd          12.1
3   202 greens       26.7
4   202 afd           5.5
5   202 left          2.9
6   202 fdp          12.9
7   201 cdu          19  
8   201 spd          14.7

Facet maps

ggplot() +
  geom_sf(
    data  = cologne_parties,
    aes(fill = vote_share),
    color = NA
  ) +
  scale_fill_viridis_c(
    option    = "plasma",
    direction = -1,
    name      = "Vote Share"
  ) +
  # one panel per party
  facet_wrap(~ party, ncol = 3) +
  theme_void() +
  theme(legend.position = "bottom") +
  labs(
    title   = "EU Election 2019 in Cologne",
    caption = "© Stadt Köln"
  )

Animated maps

gganimate extends ggplot2 with animation — add one transition layer to any existing map.

Key transition functions:

Function Use case
transition_manual(var) Step through a categorical variable (e.g. party, month name)
transition_states(var) Smooth transitions between groups
transition_time(var) Animate along a numeric time variable

Animated maps

library(gganimate)
library(gifski)

# reuse cologne_parties from the facets slide
vote_animation <-
  ggplot() +
  geom_sf(
    data  = cologne_parties,
    aes(fill = vote_share),
    color = NA
  ) +
  scale_fill_viridis_c(
    option    = "plasma",
    direction = -1,
    name      = "Vote Share"
  ) +
  theme_void() +
  theme(legend.position = "bottom") +
  labs(
    # {current_frame} is replaced by the active frame value
    title   = "EU Election 2019 in Cologne — {current_frame}",
    caption = "© Stadt Köln"
  ) +
  # one frame per party, no interpolation between categories
  gganimate::transition_manual(party)

# render (nframes = number of parties, fps = speed)
gganimate::animate(vote_animation, nframes = 6, fps = 1)

# save as GIF
gganimate::anim_save("vote_animation.gif")

Adding map labels: data preparation

To add labels, we need regular X/Y columns — not geometries. We use sf::st_centroid() to find polygon centers, then sf::st_coordinates() to extract the coordinates.

# compute polygon centroids and pull X/Y into plain columns
precinct_labels <-
  attributes_cologne |>
  sf::st_centroid() |>
  dplyr::mutate(
    X = sf::st_coordinates(geometry)[, "X"],
    Y = sf::st_coordinates(geometry)[, "Y"]
  )

precinct_labels |> dplyr::select(name, X, Y)
Simple feature collection with 86 features and 3 fields
Geometry type: POINT
Dimension:     XY
Bounding box:  xmin: 4098358 ymin: 3085516 xmax: 4118753 ymax: 3110470
Projected CRS: ETRS89-extended / LAEA Europe
# A tibble: 86 × 4
   name                     X        Y          geometry
   <chr>                <dbl>    <dbl>       <POINT [m]>
 1 202 / Marienburg  4108185. 3091764. (4108185 3091764)
 2 201 / Bayenthal   4107906. 3093194. (4107906 3093194)
 3 209 / Weiß        4112436. 3089782. (4112436 3089782)
 4 207 / Hahnwald    4108645. 3088915. (4108645 3088915)
 5 213 / Meschenich  4104993. 3087128. (4104993 3087128)
 6 212 / Immendorf   4106698. 3086747. (4106698 3086747)
 7 211 / Godorf      4108482. 3086941. (4108482 3086941)
 8 308 / Lövenich    4098358. 3097866. (4098358 3097866)
 9 307 / Weiden      4098530. 3096392. (4098530 3096392)
10 306 / Junkersdorf 4100290. 3094742. (4100290 3094742)
# ℹ 76 more rows

geom_text and geom_label

geom_text() adds plain text; geom_label() adds a text box with a background which is useful when the map underneath is busy.

# geom_text: clean, no background
cologne_map +
  theme_void() +
  geom_text(
    data     = precinct_labels,
    aes(x = X, y = Y, label = name),
    size     = 2.5,
    color    = "white",
    fontface = "bold"
  )

# geom_label: text box with background
cologne_map +
  theme_void() +
  geom_label(
    data  = precinct_labels,
    aes(x = X, y = Y, label = name),
    size  = 2.5,
    color    = "white",
    alpha = .8 #box background
  )

Interactive maps: mapview

mapview is the fastest route to an interactive map — one function call, no extra setup.

library(mapview)

# one line: instant interactive map
mapview::mapview(
  attributes_cologne,
  zcol       = "ecar",        # column to map
  layer.name = "E-Car Share"  # legend title
)

Features out of the box:

  • Pan, zoom, click on features
  • Basemap tiles automatically added
  • Works with any sf object

Interactive maps: leaflet

leaflet gives you full control over basemaps, popups, and color scales.

library(leaflet)

# leaflet requires WGS84 (EPSG:4326)
attributes_wgs84 <-
  attributes_cologne |>
  sf::st_transform(crs = 4326)

# define a color palette function
pal <- leaflet::colorNumeric(palette = "plasma", domain = attributes_wgs84$ecar)

leaflet_map <- 
  leaflet::leaflet(attributes_wgs84) |>
  leaflet::addTiles() |>                    # add basemap
  leaflet::addPolygons(
    fillColor   = ~pal(ecar),              # map color to ecar
    fillOpacity = 0.7,
    color       = "white",
    weight      = 1,
    popup       = ~paste("E-Car Share:", round(ecar, 3))  # click popup
  ) |>
  leaflet::addLegend(
    pal    = pal,
    values = ~ecar,
    title  = "E-Car Share"
  )

# save as html map  
mapview::mapshot(leaflet_map, url = "leaflet_map.html")

ggplot2 and raster data

You can also use ggplot2 to create maps with raster data. The easiest way is using the tidyterra package.

ggplot() +
  tidyterra::geom_spatraster(
    data = immigrants_cologne,
    aes(fill = immigrants_cologne)
  ) +
  # set NA values to transparent
  scale_fill_viridis_c(
    option = "magma",
    na.value = "transparent",
    name = "Share of\nImmigrants"
  ) +
  theme_void()

Add-ons with ggspatial

Typical cartographic elements are not included in ggplot2 — like a compass or scale bar.

The good thing: elements of the package ggspatial can be included as regular ggplot2 layers.

Check out paleolimbot.github.io/ggspatial.

Scale bar & north arrow

cologne_map +
  theme_void() +
  labs(
    title   = "Electric Cars in Cologne",
    caption = "© Stadt Köln; Bundesnetzagentur"
  ) +
  # scale bar: bottom-right
  ggspatial::annotation_scale(
    location = "br"
  ) +
  # north arrow: top-right
  ggspatial::annotation_north_arrow(
    location = "tr",
    style = ggspatial::north_arrow_minimal()
  )

Note On Mapping Responsibly

In the best cases, maps are easy to understand and an excellent way to transport (scientific) messages.

In the worst cases, they simplify (spurious) correlations and draw a dramatic picture of the world.

Maps can shape narratives

  • Decisions on which projection you use (remember the true size projector?),
  • The segment of the world you choose,
  • And the colors and styles you add have a strong influence.

Example: Kenneth Field’s blog post

Color vision deficiencies

The colorBlindness package simulates how your map looks for people with different types of color vision deficiency.

Created with the package colorBlindness. Viridis palettes are designed to be perceptually uniform and accessible for color vision deficiencies.

tmap: An Alternative

tmap is another popular package for thematic mapping in R:

  • Very intuitive — makes ‘good’ cartographic decisions automatically
  • Syntax based on the same grammar of graphics as ggplot2
  • Built-in support for interactive maps (tmap_mode("view"))
  • Built-in support for animated maps (tmap_animation())
  • Ideal for quick exploratory mapping

When you need interactivity or animation without additional packages, tmap is a great choice.

tmap: Quick Syntax Overview

library(tmap)

# define spatial object, then choose a display element
tm_shape(attributes_cologne) +
  tm_polygons(
    fill = "ecar",
    fill.legend = tm_legend(title = "E-Car Share")
  )

# switch to interactive mode
tmap_mode("view")
tm_shape(attributes_cologne) + tm_polygons("ecar")

# switch back to static
tmap_mode("plot")

# save options
# tmap_save(my_map, filename = "map.png")
# tmap_save(my_map, filename = "map.html")  # interactive

For learning more: r-tmap.github.io/tmap

Exercise 3_2: Fun with Maps

Exercise