Skip to contents

DonutMap creates donut charts positioned on a map from tidy data. The package has three main workflows:

  1. donut_polygons() creates an sf polygon layer.
  2. donut_map() creates a static ggplot2 map.
  3. donut_leaflet() creates an interactive leaflet map with clickable donut segments and optional links or curved trajectories.

The examples below use a Natural Earth boundary loaded with rnaturalearth (Natural Earth, n.d., https://www.naturalearthdata.com/). The point-level category values and origin-destination trajectories are simulated for demonstration.

library(DonutMap)
library(ggplot2)
library(sf)
#> Linking to GEOS 3.12.1, GDAL 3.8.4, PROJ 9.4.0; sf_use_s2() is TRUE

set.seed(20260522)

Example data

The input data are tidy: each row gives a value for one category at one location. Locations can be supplied either as longitude and latitude columns or as an sf object.

sites <- data.frame(
  site = c("Site A", "Site B", "Site C", "Site D", "Site E"),
  lon = c(-73.57, -71.21, -72.75, -68.52, -66.82),
  lat = c(45.50, 46.81, 45.40, 48.45, 50.22)
)

categories <- c("Walking", "Transit", "Car")

demo <- merge(
  sites,
  data.frame(category = categories),
  by = NULL
)

demo$value <- c(
  32, 48, 120,
  55, 80, 95,
  28, 70, 110,
  20, 44, 76,
  18, 30, 58
)

demo$category <- factor(demo$category, levels = categories)

flows <- data.frame(
  from = c(
    "Site A", "Site A", "Site B", "Site B",
    "Site C", "Site C", "Site D", "Site E"
  ),
  to = c(
    "Site B", "Site C", "Site D", "Site A",
    "Site E", "Site B", "Site E", "Site C"
  ),
  trips = c(180, 90, 120, 75, 70, 55, 60, 45),
  flow_category = c(
    "Transit", "Car", "Walking", "Transit",
    "Car", "Walking", "Walking", "Transit"
  )
)

category_colours <- c(
  Walking = "#1b9e77",
  Transit = "#7570b3",
  Car = "#d95f02"
)

For a background map, this vignette crops the Natural Earth Canada boundary to eastern Canada. The donut values and flow values remain simulated.

canada <- rnaturalearth::ne_countries(
  country = "Canada",
  returnclass = "sf"
)

eastern_canada <- sf::st_crop(
  canada,
  sf::st_bbox(
    c(xmin = -81, ymin = 44, xmax = -62, ymax = 53.5),
    crs = sf::st_crs(4326)
  )
)
#> Warning: attribute variables are assumed to be spatially constant throughout
#> all geometries

Static map

donut_map() returns a normal ggplot object, so additional ggplot2 layers, scales, labels, and themes can be added afterwards. The flows argument adds links between donut locations. flow_group colours those links by a column in the flow table, which is useful for showing the municipality, mode, or class of each connection. Use flow_curvature = 0 for straight links, and larger positive or negative values for curved trajectories.

donut_map(
  demo,
  site,
  category,
  value,
  lon = lon,
  lat = lat,
  map = eastern_canada,
  crs = 3347,
  radius_range = c(25000, 70000),
  colours = category_colours,
  flows = flows,
  from = from,
  to = to,
  flow_value = trips,
  flow_group = flow_category,
  flow_colours = category_colours,
  flow_linewidth_range = c(0.3, 2.2),
  flow_curvature = 0.22,
  flow_arrow = TRUE
) +
  labs(
    title = "Simulated mobility composition by site",
    fill = "Mode",
    linewidth = "Trips"
  ) +
  theme(legend.position = "right")

Interactive map

donut_leaflet() returns a leaflet htmlwidget. Donut segments and trajectory lines, including arrowheads, can be clicked to open popups, and hover labels are enabled by default. When flow_group is supplied, both the trajectory lines and their arrowheads are coloured by group. By default, interactive donuts are constructed in EPSG:3857, the display projection used by Leaflet, which keeps the sector boundaries visually regular on screen. Leaflet simplification is also disabled for donut polygons so the sector borders are not simplified after the widget is rendered.

donut_leaflet(
  demo,
  site,
  category,
  value,
  lon = lon,
  lat = lat,
  map = eastern_canada,
  radius_range = c(25000, 70000),
  n = 160,
  colours = category_colours,
  flows = flows,
  from = from,
  to = to,
  flow_value = trips,
  flow_group = flow_category,
  flow_colours = category_colours,
  flow_weight_range = c(1, 7),
  flow_curvature = 0.22,
  flow_arrow = TRUE,
  flow_arrow_size = 45000,
  flow_opacity = 0.75
)

Trajectory geometries

flow_lines() returns the trajectory layer directly as an sf object. This is useful when you want to build a custom map layer or inspect the geometry before plotting.

trajectories <- flow_lines(
  flows,
  demo,
  from,
  to,
  trips,
  site,
  group = flow_category,
  lon = lon,
  lat = lat,
  crs = 3347,
  flow_curvature = 0.22,
  flow_n = 40
)

trajectories
#> Simple feature collection with 8 features and 4 fields
#> Geometry type: LINESTRING
#> Dimension:     XY
#> Bounding box:  xmin: 7631610 ymin: 1244380 xmax: 7936146 ymax: 1910206
#> Projected CRS: NAD83 / Statistics Canada Lambert
#> # A tibble: 8 × 5
#>   from   to     value group                                             geometry
#>   <chr>  <chr>  <dbl> <chr>                                     <LINESTRING [m]>
#> 1 Site A Site B   180 Transit (7631610 1244380, 7632825 1250853, 7634155 125725…
#> 2 Site A Site C    90 Car     (7631610 1244380, 7633203 1245308, 7634801 124619…
#> 3 Site B Site D   120 Walking (7763098 1440475, 7763754 1448083, 7764551 145561…
#> 4 Site B Site A    75 Transit (7763098 1440475, 7761882 1434001, 7760553 142760…
#> 5 Site C Site E    70 Car     (7697210 1252459, 7696045 1271925, 7695261 129125…
#> 6 Site C Site B    55 Walking (7697210 1252459, 7696833 1258005, 7696564 126351…
#> 7 Site D Site E    60 Walking (7892189 1681853, 7890745 1688166, 7889433 169445…
#> 8 Site E Site C    45 Transit (7933770 1910206, 7934935 1890740, 7935719 187141…

Using the geometry layer directly

For more specialized workflows, donut_polygons() returns the donut segments as valid sf polygons.

donuts <- donut_polygons(
  demo,
  site,
  category,
  value,
  lon = lon,
  lat = lat,
  crs = 3347,
  radius_range = c(25000, 70000)
)

donuts
#> Simple feature collection with 15 features and 8 fields
#> Geometry type: POLYGON
#> Dimension:     XY
#> Bounding box:  xmin: 7590543 ymin: 1182476 xmax: 7963944 ymax: 1940386
#> Projected CRS: NAD83 / Statistics Canada Lambert
#> # A tibble: 15 × 9
#>    id     category value total proportion radius start_angle end_angle
#>    <chr>  <fct>    <dbl> <dbl>      <dbl>  <dbl>       <dbl>     <dbl>
#>  1 Site A Walking     32   171     0.187  41076.       1.57      0.395
#>  2 Site A Transit     95   171     0.556  41076.       0.395    -3.10 
#>  3 Site A Car         44   171     0.257  41076.      -3.10     -4.71 
#>  4 Site B Walking     48   152     0.316  25000        1.57     -0.413
#>  5 Site B Transit     28   152     0.184  25000       -0.413    -1.57 
#>  6 Site B Car         76   152     0.5    25000       -1.57     -4.71 
#>  7 Site C Walking    120   208     0.577  70000        1.57     -2.05 
#>  8 Site C Transit     70   208     0.337  70000       -2.05     -4.17 
#>  9 Site C Car         18   208     0.0865 70000       -4.17     -4.71 
#> 10 Site D Walking     55   195     0.282  60155.       1.57     -0.201
#> 11 Site D Transit    110   195     0.564  60155.      -0.201    -3.75 
#> 12 Site D Car         30   195     0.154  60155.      -3.75     -4.71 
#> 13 Site E Walking     80   158     0.506  30180.       1.57     -1.61 
#> 14 Site E Transit     20   158     0.127  30180.      -1.61     -2.41 
#> 15 Site E Car         58   158     0.367  30180.      -2.41     -4.71 
#> # ℹ 1 more variable: geometry <POLYGON [m]>

You can use that object with any package that accepts sf polygons.

plot(donuts["category"])