1 Libraries, IDs & shared spatial layers
library(tidyverse)
library(lubridate)
library(sf)
library(terra)
library(MASS)
library(adehabitatHR)
library(sp)
library(patchwork)
library(rnaturalearth)
library(ggnewscale)
library(smoothr)
library(units)
library(ctmm)
past_ids_vec <- c(
"AG082", "AG083", "AG088", "AG089", "AG096", "AG432",
"st2010-1048_2", "st2010-1052", "st2010-1053"
)
present_ids_vec <- c(
"171604 - adWBV05", "171605 - Matira", "171606 - Ilkeliani",
"171607 - Baldrick", "171608 - adWBV06", "171609 - Charlie",
"171612 - Olarro", "171613 - Tipilikwani"
)
all_ids <- c(past_ids_vec, present_ids_vec)
pas <- sf::st_read(
"/Users/oli/University/Zoology/5th_Year_MSc/MSC_Thesis/GIS/FINAL_OUTPUTS.gpkg",
quiet = TRUE
)
snp <- pas[pas$NAME == "Serengeti National Park", ]
mara <- pas[pas$NAME == "Masai Mara", ]
other_pas <- pas[!pas$NAME %in% c("Serengeti National Park", "Masai Mara"), ]
gsme <- sf::st_read(
"/Users/oli/University/Zoology/5th_Year_MSc/MSC_Thesis/GIS/V7_Serengeti_Ecosystem_Boundary.shp",
quiet = TRUE
) %>% sf::st_transform(4326)
world <- ne_countries(scale = 50, returnclass = "sf")
dir.create("figures_final", showWarnings = FALSE)
gps <- readRDS("WBV_resampled_2h.rds") %>%
rename(t_ = timestamp, x_ = longitude, y_ = latitude)
akdes <- readRDS("WBV_akdes.rds")
akdes_wet <- readRDS("WBV_std_akdes_wet.rds")
akdes_dry <- readRDS("WBV_std_akdes_dry.rds")
ud_areas <- readRDS("WBV_ud_areas.rds")
pa_overlap <- readRDS("WBV_pa_overlap.rds")
all_contours <- readRDS("WBV_pop_ud_contours.rds")
ind_smooth_contours <- readRDS("WBV_ind_seasonal_contours.rds")
centroid_dist <- readRDS("WBV_centroid_dist.rds")
vul_nsd <- readRDS("WBV_nsd.rds")
zone_pct <- readRDS("WBV_zone_pct.rds")
wb_mig_past <- readRDS("WB_mig_past_proxy.rds")
Figure 4 — Population-level UD contours
all_contours_plot <- all_contours %>%
mutate(period = factor(period, c("Past", "Present")),
level = factor(level, c("95%", "75%", "50%", "25%")))
fig4 <- ggplot() +
geom_sf(data = world, fill = "grey93", colour = "grey70", linewidth = 0.3) +
geom_sf(data = other_pas, fill = "#d9ead3", colour = "grey60", linewidth = 0.2) +
geom_sf(data = mara, fill = "#d9ead3", colour = "#3a7d44", linewidth = 0.5) +
geom_sf(data = snp, fill = "#d9ead3", colour = "#3a7d44", linewidth = 0.7) +
geom_sf(data = gsme, fill = NA, colour = "grey20", linewidth = 0.5,
linetype = "dashed") +
geom_sf(data = all_contours_plot,
aes(fill = level, colour = level), alpha = 0.4, linewidth = 0.5) +
scale_fill_manual(
values = c("95%" = "#fddbc7", "75%" = "#f4a582",
"50%" = "#d6604d", "25%" = "#b2182b"), name = "UD level") +
scale_colour_manual(
values = c("95%" = "#fddbc7", "75%" = "#f4a582",
"50%" = "#d6604d", "25%" = "#b2182b"), name = "UD level") +
coord_sf(xlim = c(33.6, 36.4), ylim = c(-4.0, 1.0), expand = FALSE) +
facet_wrap(~ period) +
labs(x = NULL, y = NULL) +
theme_bw(base_size = 12) +
theme(strip.background = element_rect(fill = "grey20"),
strip.text = element_text(colour = "white", face = "bold", size = 11),
legend.position = "bottom",
panel.grid = element_line(colour = "grey88", linewidth = 0.2))
ggsave("figures_final/WBV_ud_contours_map_pa.png", fig4,
width = 10, height = 6, dpi = 300, bg = "white")
print(fig4)

Figure 6 — Seasonal population space use vs wildebeest density
make_kde_df <- function(lon, lat, h = 0.3, thresh_q = 0.35) {
k <- kde2d(lon, lat, h = h, n = 300, lims = c(33.5, 36.5, -4.0, -0.8))
m <- t(k$z)[nrow(t(k$z)):1, ]
r <- rast(m, extent = c(33.5, 36.5, -4.0, -0.8), crs = "EPSG:4326")
r <- log10(r + 1e-10)
df <- as.data.frame(r, xy = TRUE)
names(df)[3] <- "logdens"
df$logdens[df$logdens < quantile(df$logdens, thresh_q, na.rm = TRUE)] <- NA
df
}
make_contour_lines_sf <- function(lon, lat, h = 0.12, ud_levels = c(0.75, 0.95)) {
k <- kde2d(lon, lat, h = h, n = 200, lims = c(33.5, 36.5, -4.0, -0.8))
z <- k$z
cell <- diff(k$x[1:2]) * diff(k$y[1:2])
z_norm <- z / (sum(z) * cell)
z_sorted <- sort(as.vector(z_norm), decreasing = TRUE)
z_cum <- cumsum(z_sorted) * cell
thresholds <- sapply(ud_levels, function(lev) z_sorted[which(z_cum >= lev)[1]])
line_list <- lapply(seq_along(ud_levels), function(i) {
cl <- contourLines(x = k$x, y = k$y, z = z_norm, levels = thresholds[i])
if (length(cl) == 0) return(NULL)
cl <- cl[sapply(cl, function(s) length(s$x)) > 30]
if (length(cl) == 0) return(NULL)
lines <- lapply(cl, function(seg) st_linestring(cbind(seg$x, seg$y)))
sf::st_sf(level = paste0(ud_levels[i] * 100, "%"),
geometry = st_sfc(lines, crs = 4326))
})
do.call(rbind, Filter(Negate(is.null), line_list))
}
wb_sf <- sf::st_as_sf(
wb_mig_past %>% filter(!is.na(UTM_X)),
coords = c("UTM_X", "UTM_Y"), crs = 32736
) %>% sf::st_transform(4326)
wb_coords <- sf::st_coordinates(wb_sf)
wb_mig_past$lon <- wb_coords[, 1]
wb_mig_past$lat <- wb_coords[, 2]
combos <- list(
list(period = "Past", season = "Wet"),
list(period = "Past", season = "Dry"),
list(period = "Present", season = "Wet"),
list(period = "Present", season = "Dry")
)
wb_df_list <- list(); cont_sf_list <- list()
for (x in combos) {
key <- paste(x$period, x$season)
wb_sub <- wb_mig_past %>% filter(season == x$season)
df <- make_kde_df(wb_sub$lon, wb_sub$lat)
df$period <- x$period; df$season <- x$season
wb_df_list[[key]] <- df
gps_sub <- gps %>% filter(period == x$period, season == x$season)
cont <- tryCatch(
make_contour_lines_sf(gps_sub$x_, gps_sub$y_, h = 0.12),
error = function(e) NULL
)
if (!is.null(cont)) {
cont$period <- x$period; cont$season <- x$season
cont_sf_list[[key]] <- cont
}
}
wb_df_all <- do.call(rbind, wb_df_list) %>%
mutate(period = factor(period, c("Past","Present")),
season = factor(season, c("Wet","Dry")))
cont_all <- do.call(rbind, cont_sf_list) %>%
mutate(period = factor(period, c("Past","Present")),
season = factor(season, c("Wet","Dry")))
fig6 <- ggplot() +
geom_raster(data = wb_df_all %>% filter(!is.na(logdens)),
aes(x = x, y = y, fill = logdens), alpha = 0.85) +
scale_fill_gradientn(
colours = c("#FFF5E0","#FDCC8A","#FC8D59","#D7301F","#7F0000"),
name = "Wildebeest\nlog density", na.value = "transparent") +
geom_sf(data = gsme, fill = NA, colour = "black", linewidth = 0.5, linetype = "dashed") +
geom_sf(data = other_pas, fill = NA, colour = "#3a7d44", linewidth = 0.4) +
geom_sf(data = mara, fill = NA, colour = "#3a7d44", linewidth = 0.7) +
geom_sf(data = snp, fill = NA, colour = "#3a7d44", linewidth = 0.9) +
geom_sf(data = cont_all %>% filter(level == "95%"),
colour = "#1a1a2e", linewidth = 0.6, alpha = 0.6) +
geom_sf(data = cont_all %>% filter(level == "75%"),
colour = "#1a1a2e", linewidth = 1.2, alpha = 1.0) +
facet_grid(period ~ season) +
coord_sf(xlim = c(33.5, 36.5), ylim = c(-4.0, -0.8), expand = FALSE) +
labs(x = NULL, y = NULL) +
theme_classic(base_size = 12) +
theme(strip.background = element_rect(fill = "grey20"),
strip.text = element_text(colour = "white", face = "bold", size = 12),
legend.position = "bottom",
axis.text = element_text(size = 8),
panel.spacing = unit(0.4, "lines"))
ggsave("figures_final/WBV_seasonal_vs_wildebeest_pop_v7.png", fig6,
width = 10, height = 10, dpi = 300, bg = "white")
print(fig6)

Figure 7a & 7b — Individual seasonal home ranges vs
wildebeest
make_wb_rast <- function(df, h = 5000, grid = 300) {
coords <- df %>% filter(!is.na(UTM_X), !is.na(UTM_Y))
sp_obj <- SpatialPoints(cbind(coords$UTM_X, coords$UTM_Y),
proj4string = CRS("+proj=utm +zone=36 +south +datum=WGS84"))
ud <- kernelUD(sp_obj, h = h, grid = grid)
spdf <- as(ud, "SpatialPixelsDataFrame")
r <- terra::rast(spdf)
terra::crs(r) <- "+proj=utm +zone=36 +south +datum=WGS84"
r_ll <- terra::project(r, "EPSG:4326")
r_ll / terra::global(r_ll, "max", na.rm = TRUE)$max
}
wb_past_wet <- make_wb_rast(wb_mig_past %>% filter(season == "Wet"))
wb_past_dry <- make_wb_rast(wb_mig_past %>% filter(season == "Dry"))
wb_df <- bind_rows(
as.data.frame(wb_past_wet, xy = TRUE) %>% rename(dens = 3) %>% mutate(period = "Past", season = "Wet"),
as.data.frame(wb_past_dry, xy = TRUE) %>% rename(dens = 3) %>% mutate(period = "Past", season = "Dry"),
as.data.frame(wb_past_wet, xy = TRUE) %>% rename(dens = 3) %>% mutate(period = "Present", season = "Wet"),
as.data.frame(wb_past_dry, xy = TRUE) %>% rename(dens = 3) %>% mutate(period = "Present", season = "Dry")
)
cont95 <- ind_smooth_contours %>%
filter(level == "95%") %>%
smoothr::drop_crumbs(threshold = units::set_units(300, km^2)) %>%
smoothr::smooth(method = "ksmooth", smoothness = 3) %>%
mutate(id_short = factor(gsub("^[0-9]+ - ", "", id)))
make_period_plot <- function(period_val, ids, strip_col, wb_season, wb_colours) {
sub_kde <- cont95 %>% filter(id %in% ids)
wb_bg <- wb_df %>% filter(period == period_val, season == wb_season, dens > 0.1)
ggplot() +
geom_sf(data = world, fill = "grey97", colour = "grey75", linewidth = 0.15) +
geom_raster(data = wb_bg, aes(x = x, y = y, fill = dens),
interpolate = TRUE, alpha = 0.75) +
scale_fill_gradientn(colours = wb_colours, limits = c(0, 1), guide = "none") +
geom_sf(data = other_pas, fill = NA, colour = "grey65", linewidth = 0.15) +
geom_sf(data = mara, fill = NA, colour = "#3a7d44", linewidth = 0.3) +
geom_sf(data = snp, fill = NA, colour = "#3a7d44", linewidth = 0.4) +
geom_sf(data = gsme, fill = NA, colour = "grey35", linewidth = 0.4, linetype = "dashed") +
geom_sf(data = sub_kde %>% filter(season == "Wet"),
fill = NA, colour = "#2166ac", linewidth = 0.8) +
geom_sf(data = sub_kde %>% filter(season == "Dry"),
fill = NA, colour = "#b2182b", linewidth = 0.8) +
geom_hline(yintercept = -1.5, colour = "grey60", linewidth = 0.2, linetype = "longdash") +
coord_sf(xlim = c(33.6, 36.4), ylim = c(-4.0, 1.0), expand = FALSE) +
facet_wrap(~ id_short, nrow = 2) +
labs(title = period_val, x = NULL, y = NULL) +
theme_bw(base_size = 9) +
theme(
plot.title = element_text(colour = "white", face = "bold", hjust = 0.5,
size = 12, margin = margin(t = 4, b = 4)),
strip.background = element_rect(fill = "grey88"),
strip.text = element_text(size = 7.5, face = "bold"),
panel.grid = element_blank(),
axis.text = element_text(size = 5),
plot.background = element_rect(fill = strip_col, colour = strip_col),
plot.margin = margin(6, 4, 4, 4)
)
}
wet_colours <- c("#ffe0b2", "#ff8c00", "#bf360c")
p7a_past <- make_period_plot("Past", past_ids_vec, "#2d4a6e", "Wet", wet_colours)
p7a_present <- make_period_plot("Present", present_ids_vec, "#2d5a2d", "Wet", wet_colours)
fig7a <- (p7a_past / p7a_present) +
plot_annotation(
title = "Individual seasonal home ranges vs migratory wildebeest",
subtitle = "Blue outline = Wet KDE (95%) | Red outline = Dry KDE (95%) | Orange = wet-season wildebeest density",
theme = theme(plot.title = element_text(face = "bold", size = 12),
plot.subtitle = element_text(size = 9, colour = "grey40")))
ggsave("figures_final/WBV_ind_seasonal_vs_wildebeest_v9.png", fig7a,
width = 12, height = 10, dpi = 300, bg = "white")
print(fig7a)

dry_colours <- c("#e8f5e9", "#4caf50", "#1b5e20")
p7b_past <- make_period_plot("Past", past_ids_vec, "#2d4a6e", "Dry", dry_colours)
p7b_present <- make_period_plot("Present", present_ids_vec, "#2d5a2d", "Dry", dry_colours)
fig7b <- (p7b_past / p7b_present) +
plot_annotation(
title = "Individual seasonal home ranges vs migratory wildebeest",
subtitle = "Blue outline = Wet KDE (95%) | Red outline = Dry KDE (95%) | Green = dry-season wildebeest density",
theme = theme(plot.title = element_text(face = "bold", size = 12),
plot.subtitle = element_text(size = 9, colour = "grey40")))
ggsave("figures_final/WBV_ind_seasonal_vs_wildebeest_dry_v1.png", fig7b,
width = 12, height = 10, dpi = 300, bg = "white")
print(fig7b)

Figure 8 — Seasonal centroid displacement map
centroid_dist_plot <- centroid_dist %>%
mutate(period = factor(period, c("Past", "Present")))
fig8 <- ggplot() +
geom_sf(data = world, fill = "grey97", colour = "grey80", linewidth = 0.2) +
geom_sf(data = other_pas, fill = "#e8f4e8", colour = "grey70", linewidth = 0.25) +
geom_sf(data = mara, fill = "#e8f4e8", colour = "#3a7d44", linewidth = 0.4) +
geom_sf(data = snp, fill = "#e8f4e8", colour = "#3a7d44", linewidth = 0.5) +
geom_sf(data = gsme, fill = NA, colour = "grey40", linewidth = 0.5, linetype = "dashed") +
geom_segment(
data = centroid_dist_plot,
aes(x = wet_lon, y = wet_lat, xend = dry_lon, yend = dry_lat, colour = period),
arrow = arrow(length = unit(0.18, "cm"), type = "closed"),
linewidth = 0.7, alpha = 0.85) +
geom_point(data = centroid_dist_plot,
aes(x = wet_lon, y = wet_lat, colour = period),
shape = 21, fill = "white", size = 3, stroke = 1.2) +
geom_point(data = centroid_dist_plot,
aes(x = dry_lon, y = dry_lat, colour = period),
shape = 22, fill = "white", size = 3, stroke = 1.2) +
geom_hline(yintercept = -1.5, colour = "grey60", linewidth = 0.2, linetype = "longdash") +
scale_colour_manual(values = c(Past = "#2d4a6e", Present = "#b2182b"), guide = "none") +
coord_sf(xlim = c(33.8, 36.2), ylim = c(-3.8, 0.8), expand = FALSE) +
facet_wrap(~ period) +
labs(subtitle = "Circle = wet centroid | Square = dry centroid", x = NULL, y = NULL) +
theme_bw(base_size = 12) +
theme(strip.background = element_rect(fill = "grey88"),
strip.text = element_text(face = "bold", size = 12),
panel.grid = element_blank(),
plot.subtitle = element_text(size = 9, colour = "grey40"))
ggsave("figures_final/WBV_seasonal_centroid_map_v2.png", fig8,
width = 9, height = 6, dpi = 300, bg = "white")
print(fig8)

Session info
sessionInfo()
## R version 4.6.0 (2026-04-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Sequoia 15.7.4
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## time zone: Europe/London
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] ctmm_1.3.0 units_1.0-1 smoothr_1.3.0
## [4] ggnewscale_0.5.2 rnaturalearth_1.2.0 patchwork_1.3.2
## [7] adehabitatHR_0.4.22 adehabitatLT_0.3.29 adehabitatMA_0.3.17
## [10] ade4_1.7-24 sp_2.2-1 MASS_7.3-65
## [13] terra_1.9-27 sf_1.1-1 lubridate_1.9.5
## [16] forcats_1.0.1 stringr_1.6.0 dplyr_1.2.1
## [19] purrr_1.2.2 readr_2.2.0 tidyr_1.3.2
## [22] tibble_3.3.1 ggplot2_4.0.3 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 raster_3.6-32 xfun_0.58
## [4] bslib_0.11.0 lattice_0.22-9 tzdb_0.5.0
## [7] vctrs_0.7.3 tools_4.6.0 generics_0.1.4
## [10] proxy_0.4-29 pkgconfig_2.0.3 KernSmooth_2.23-26
## [13] RColorBrewer_1.1-3 S7_0.2.2 lifecycle_1.0.5
## [16] compiler_4.6.0 farver_2.1.2 textshaping_1.0.5
## [19] codetools_0.2-20 htmltools_0.5.9 class_7.3-23
## [22] sass_0.4.10 yaml_2.3.12 pillar_1.11.1
## [25] jquerylib_0.1.4 classInt_0.4-11 cachem_1.1.0
## [28] wk_0.9.5 rnaturalearthdata_1.0.0 tidyselect_1.2.1
## [31] digest_0.6.39 stringi_1.8.7 labeling_0.4.3
## [34] fastmap_1.2.0 grid_4.6.0 cli_3.6.6
## [37] magrittr_2.0.5 e1071_1.7-17 withr_3.0.2
## [40] scales_1.4.0 timechange_0.4.0 rmarkdown_2.31
## [43] otel_0.2.0 ragg_1.5.2 hms_1.1.4
## [46] evaluate_1.0.5 knitr_1.51 s2_1.1.11
## [49] rlang_1.3.0 Rcpp_1.1.1-1.1 glue_1.8.1
## [52] DBI_1.3.0 rstudioapi_0.18.0 jsonlite_2.0.0
## [55] R6_2.6.1 systemfonts_1.3.2