Skip to main content

World Castles, Fortresses and Palaces

TidyTuesday 2026-09-01

r
ggplot2
tidyverse
data-viz
Author

gnoblet

Published

September 1, 2026

Overview

This week we’re exploring the Castlemap dataset of 5,793 castles, fortresses, palaces and ruins in 138 countries. The data comes from Wikidata, and every landmark has verified coordinates, a Wikipedia article and a photo on Wikimedia Commons. Each also has a fame rank, based on how many languages have an article on it and how often that article is read. The Great Wall of China is first.

Tip

Please cite, it’s nice! — Guillaume Noblet (2026). Tidy Tuesday Gallery. https://gnoblet.codeberg.page/TidyTuesday

Visualization

Libraries and Data

library(dplyr)
library(tidyr)
library(readr)
library(stringr)
library(ggplot2)
library(ggtext)
library(ggrepel)
library(ggbranding)
library(showtext)
library(AddFonts)
library(patchwork)
library(sf)
library(rnaturalearth)
library(magick)

dat <- read_csv("https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2026/2026-09-01/world_castles.csv")
# Map bbox: space in atlantic ocean for Paris zoom in and violin plot
xmin_fr <- -14
xmax_fr <- 10.5
ymin_fr <- 36.4
ymax_fr <- 53

# Keep France only
castles_fr <- dat |> filter(country == "France")

# Planar geometry is fine at this scale
sf_use_s2(FALSE)

# Land is cropped a degree wider than the map, so the cut edges fall outside the frame
crop_wide <- function(x) {
  st_crop(x, xmin = xmin_fr - 1, xmax = xmax_fr + 1, ymin = ymin_fr - 1, ymax = ymax_fr + 1)
}

world_crop  <- crop_wide(ne_download(scale = 10, type = "countries", category = "cultural", returnclass = "sf", load = TRUE))
france_poly <- world_crop |> filter(ADMIN == "France")
land        <- st_union(world_crop)
coastline   <- st_boundary(land)

# Water lines: rings rippling off all coasts, fading out as they enter the sea. 
water_lines <- purrr::map(c(8, 17, 28, 42, 60), function(km) {
  ring <- st_transform(land, 3035) |> st_buffer(km * 1000) |> st_boundary() |> st_transform(4326)
  st_sf(km = km, geometry = ring)
}) |>
  purrr::list_rbind()

# Major rivers plus Natural Earth's supplementary European rivers
read_rivers <- function(type, size) {
  r <- ne_download(scale = 10, type = type, category = "physical", returnclass = "sf", load = TRUE)
  st_sf(size = size, geometry = st_geometry(st_make_valid(r)))
}
rivers_fr <- rbind(
  read_rivers("rivers_europe", "minor"),
  read_rivers("rivers_lake_centerlines", "major")
) |>
  crop_wide()

# France river details
rivers_france <- st_intersection(rivers_fr, st_geometry(france_poly))

# Most famous landmarks
famous <- castles_fr |>
  filter(!is.na(fame_rank)) |>
  arrange(fame_rank) |>
  slice_head(n = 15)

# Paris loupe. Paris is far too dense to read at national scale
paris_lon   <- 2.3522
paris_lat   <- 48.8566
paris_r_deg <- 0.35
paris_rx    <- paris_r_deg / cos(paris_lat * pi / 180)

lon_scale <- cos(mean(c(ymin_fr, ymax_fr)) * pi / 180)
loupe_x   <- -9.4
loupe_y   <- 50.2
loupe_r   <- 2.3
mag       <- loupe_r / paris_r_deg

to_loupe_x <- function(lon) loupe_x + mag * (lon - paris_lon) * cos(paris_lat * pi / 180) / lon_scale
to_loupe_y <- function(lat) loupe_y + mag * (lat - paris_lat)

in_paris <- function(lon, lat) ((lon - paris_lon) / paris_rx)^2 + ((lat - paris_lat) / paris_r_deg)^2 <= 1

circle <- function(cx, cy, rx, ry, n = 240) {
  t <- seq(0, 2 * pi, length.out = n)
  tibble(x = cx + rx * cos(t), y = cy + ry * sin(t))
}

paris_ring <- circle(paris_lon, paris_lat, paris_rx, paris_r_deg)
loupe_disc <- circle(loupe_x, loupe_y, loupe_r / lon_scale, loupe_r)

castles_paris <- castles_fr |>
  filter(in_paris(lon, lat)) |>
  mutate(x = to_loupe_x(lon), y = to_loupe_y(lat))

paris_labels <- castles_paris |>
  filter(!is.na(fame_rank)) |>
  arrange(fame_rank) |>
  slice_head(n = 7)

# The Seine through Paris
paris_window <- st_sfc(st_polygon(list(as.matrix(paris_ring))), crs = 4326)
seine <- st_intersection(st_geometry(rivers_fr |> filter(size == "major")), paris_window)
seine_loupe <- (seine - c(paris_lon, paris_lat)) *
  diag(c(mag * cos(paris_lat * pi / 180) / lon_scale, mag)) + c(loupe_x, loupe_y)
seine_loupe <- st_set_crs(seine_loupe, 4326)

# Thin leader from the Paris ring to the loupe
loupe_link <- tibble(
  x = paris_lon - paris_rx, y = paris_lat,
  xend = loupe_x + loupe_r / lon_scale, yend = loupe_y
)

famous <- famous |> mutate(paris = in_paris(lon, lat))

# Construction years for the raincloud chart.
# Remove the few very early ruins (before 1000 BCE) to not stretch the axis too much
year_dat <- castles_fr |>
  filter(!is.na(year), year >= -1000) |>
  mutate(category = stats::reorder(category, year, FUN = median))

set.seed(35)
rain_dat <- year_dat |>
  slice_sample(n = 300, by = category) |>
  mutate(x_jitter = as.numeric(category) - 0.18 + stats::runif(n(), -0.09, 0.09))

medians <- year_dat |> summarise(med = round(median(year)), .by = category)
med_of  <- function(cat) medians$med[medians$category == cat]

country_counts <- dat |> count(country, sort = TRUE)
france_rank <- which(country_counts$country == "France")

# Map annotations: neighbours in capitals, seas in italics
country_names <- tribble(
  ~label,            ~lon,  ~lat,
  "UNITED KINGDOM",  -1.3,  52.35,
  "NETHERLANDS",      5.6,  52.45,
  "BELGIUM",          4.6,  50.55,
  "GERMANY",          8.7,  50.3,
  "SWITZERLAND",      8.1,  46.85,
  "ITALY",            9.0,  44.75,
  "SPAIN",           -3.6,  46.8,
  "PORTUGAL",        -8.3,  39.15
)
sea_names <- tribble(
  ~label,              ~lon,  ~lat,
  "Atlantic Ocean",   loupe_x, loupe_y - loupe_r - 2,
  "Bay of\nBiscay",    -2.6,  45.3,
  "English Channel",   -2.2,  49.95,
  "North Sea",          3.0,  52.7,
  "Mediterranean Sea",  5.8,  40.7
)
# Fonts
main_font <- 'Libre Franklin'
map_font  <- 'Fira Sans'
add_font(main_font)
add_font(map_font)
showtext_auto()
showtext_opts(dpi = 300)

# Atlas palette:
page      <- '#FBF8F1'
ink       <- '#1A1A1A'
muted     <- '#6B6B6B'
sea       <- '#D3E4EE'
water     <- '#5F93B3'
neighbour <- '#EEE6D6'
france    <- '#FFFEFA'
border    <- '#C4B7A0'
red       <- '#E3120B'

category_palette <- c(
  fortress = red,
  palace   = '#0F3B63',
  castle   = '#7E9CBB',
  ruin     = '#A99F8F'
)
category_order <- c('fortress', 'palace', 'castle', 'ruin')

title <- "Fortresses are France's youngest landmarks"
subtitle <- str_glue(
  "France holds {nrow(castles_fr)} of the 5,793 castles, fortresses, palaces and ruins in the Castlemap ",
  "dataset. Its castles and ruins are medieval (median build years ",
  "{med_of('castle')} and {med_of('ruin')}), palaces came later ({med_of('palace')}). Fortresses came",
  "later still and half were built after **{med_of('fortress')}**. Diamonds mark the 15 most famous landmarks"
)

caption <- branding(
  github = 'gnoblet', bluesky = 'gnoblet.eurosky.social', website = 'guillaume-noblet.com',
  additional_text = paste(
    'Source: Wikidata, via the Castlemap dataset | #TidyTuesday (2026-09-01)',
    sep = '<br>'
  ),
  text_position = 'after', line_spacing = 2L,
  text_color = muted, icon_color = red, additional_text_color = muted,
  text_size = '13pt', icon_size = '13pt', additional_text_size = '13pt'
)

p_map <- ggplot() +
  # Sea
  geom_sf(data = water_lines, aes(alpha = km), colour = water, linewidth = 0.3) +
  scale_alpha(range = c(0.45, 0.08), guide = 'none') +
  # Land
  geom_sf(data = world_crop, fill = neighbour, colour = border, linewidth = 0.3) +
  geom_sf(data = france_poly, fill = france, colour = '#8C8170', linewidth = 0.5) +
  # Rivers
  geom_sf(data = rivers_fr |> filter(size == 'major'), colour = water, alpha = 0.35, linewidth = 0.35) +
  geom_sf(data = rivers_france |> filter(size == 'minor'), colour = water, alpha = 0.8, linewidth = 0.3) +
  geom_sf(data = rivers_france |> filter(size == 'major'), colour = water, linewidth = 0.55) +
  # Coastline
  geom_sf(data = coastline, colour = water, linewidth = 0.45) +
  # Country labels
  geom_text(
    data = country_names, aes(lon, lat, label = label),
    family = map_font, colour = '#948A78', size = 4
  ) +
  # Sea labels
  geom_text(
    data = sea_names, aes(lon, lat, label = label),
    family = map_font, fontface = 'italic', colour = '#4F7F9E', size = 4, lineheight = 0.9
  ) +
  # Castles
  geom_point(
    data = castles_fr |> mutate(category = factor(category, category_order)),
    aes(lon, lat, fill = category),
    shape = 21, size = 2.4, colour = 'white', stroke = 0.3
  ) +
  # Famous castles
  geom_point(
    data = famous, aes(lon, lat),
    shape = 23, size = 3.6, fill = ink, colour = 'white', stroke = 0.5
  ) +
  # Keep the national labels off the ring
  geom_text_repel(
    data = bind_rows(
      famous |> filter(!paris) |> select(lon, lat, name),
      paris_ring |> slice(seq(1, n(), by = 12)) |> mutate(lon = x, lat = y, name = '', .keep = 'none')
    ),
    aes(lon, lat, label = name),
    family = map_font, size = 4, colour = ink,
    box.padding = 0.5, point.padding = 0.3, segment.colour = ink, segment.size = 0.3,
    min.segment.length = 0, max.overlaps = Inf, seed = 35,
    bg.color = 'white', bg.r = 0.15
  ) +
  # Paris ring, leader and loupe
  geom_path(data = paris_ring, aes(x, y), colour = ink, linewidth = 0.5) +
  geom_segment(
    data = loupe_link, aes(x = x, y = y, xend = xend, yend = yend),
    colour = ink, linewidth = 0.35, linetype = '22'
  ) +
  geom_polygon(data = loupe_disc, aes(x, y), fill = france, colour = ink, linewidth = 0.7) +
  geom_sf(data = seine_loupe, colour = water, linewidth = 1.1) +
  geom_point(
    data = castles_paris |> mutate(category = factor(category, category_order)),
    aes(x, y, fill = category),
    shape = 21, size = 3.2, colour = 'white', stroke = 0.3
  ) +
  geom_point(
    data = paris_labels, aes(x, y),
    shape = 23, size = 3.6, fill = ink, colour = 'white', stroke = 0.5
  ) +
  geom_text_repel(
    data = paris_labels, aes(x, y, label = name),
    family = map_font, size = 3.6, colour = ink,
    box.padding = 0.55, point.padding = 0.3, force = 3, segment.colour = ink, segment.size = 0.25,
    min.segment.length = 0, max.overlaps = Inf, seed = 35,
    bg.color = 'white', bg.r = 0.15,
    xlim = loupe_x + c(-1, 1) * 0.92 * loupe_r / lon_scale,
    ylim = loupe_y + c(-1, 1) * 0.92 * loupe_r
  ) +
  annotate(
    'text', x = loupe_x, y = loupe_y - loupe_r - 0.35, label = 'PARIS',
    family = map_font, fontface = 'bold', size = 4.2, colour = ink
  ) +
  # Legend
  scale_fill_manual(
    values = category_palette, breaks = category_order,
    labels = str_to_title(category_order), name = NULL
  ) +
  guides(fill = guide_legend(nrow = 1, override.aes = list(size = 5))) +
  # Labels
  labs(title = title, subtitle = subtitle, caption = caption) +
  coord_sf(xlim = c(xmin_fr, xmax_fr), ylim = c(ymin_fr, ymax_fr), expand = FALSE) +
  # Theme
  theme_void(base_family = main_font) +
  theme(
    plot.background       = element_rect(fill = page, colour = NA),
    panel.background      = element_rect(fill = sea, colour = NA),
    plot.margin           = margin(40, 35, 15, 35),
    plot.title.position   = 'plot',
    plot.caption.position = 'plot',
    plot.title = element_textbox_simple(
      family = main_font, face = 'bold', size = 32, colour = ink,
      lineheight = 1.1, margin = margin(b = 10)
    ),
    plot.subtitle = element_textbox_simple(
      family = main_font, size = 16, colour = '#333333', lineheight = 1.35, margin = margin(b = 12)
    ),
    plot.caption = element_textbox_simple(
      family = main_font, colour = muted, halign = 0, margin = margin(t = 10)
    ),
    legend.position      = 'top',
    legend.justification = 'left',
    legend.text          = element_text(family = main_font, size = 14, colour = ink, margin = margin(r = 14)),
    legend.key           = element_rect(fill = NA, colour = NA),
    legend.margin        = margin(b = 6)
  )

# Raincloud chart: half-violins with a narrow boxplot, and the raw years as rain underneath. 
cat_label <- function(x) sprintf("<span style='color:%s'>**%s**</span>", category_palette[x], str_to_title(x))

p_inset <- ggplot(year_dat, aes(x = category, y = year)) +
  geom_violin(
    aes(fill = category), trim = TRUE, alpha = 0.6, colour = NA,
    position = position_nudge(x = 0.15), width = 0.9
  ) +
  geom_boxplot(
    width = 0.1, outlier.shape = NA, linewidth = 0.4, fill = 'white', colour = ink,
    position = position_nudge(x = 0.15)
  ) +
  geom_point(
    data = rain_dat, aes(x = x_jitter, y = year, colour = category),
    inherit.aes = FALSE, size = 0.9, alpha = 0.55
  ) +
  scale_fill_manual(values = category_palette, guide = 'none') +
  scale_colour_manual(values = category_palette, guide = 'none') +
  scale_x_discrete(labels = cat_label) +
  coord_flip() +
  labs(
    title = 'When were they built?',
    x = NULL, y = NULL
  ) +
  theme_minimal(base_size = 13, base_family = main_font) +
  theme(
    plot.background    = element_rect(fill = 'white', colour = '#D8D4CC', linewidth = 0.4),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank(),
    panel.grid.major.x = element_line(colour = '#E6E2DA', linewidth = 0.3),
    # coord_flip draws the category axis with axis.text.y.left, which is
    # plain text in the base theme, so the markdown element goes there
    axis.text.y.left   = element_markdown(size = 13, hjust = 0),
    axis.text.x        = element_text(colour = muted, size = 11),
    plot.title         = element_text(face = 'bold', size = 15, colour = ink),
    plot.title.position = 'plot',
    plot.margin        = margin(10, 14, 8, 10)
  )

# Red tab at the top-left
red_tab <- ggplot() +
  annotate('rect', xmin = 0, xmax = 1, ymin = 0, ymax = 1, fill = red) +
  theme_void()

# Hop final insetting
p <- p_map +
  inset_element(p_inset, left = 0.015, right = 0.45, bottom = 0.015, top = 0.45, align_to = 'panel') +
  inset_element(red_tab, left = 0, right = 0.09, bottom = 0.993, top = 1, align_to = 'full') +
  plot_annotation(theme = theme(plot.background = element_rect(fill = page, colour = NA)))
ggsave(
  'week_35.png',
  plot   = p,
  width  = 12,
  height = 14.7,
  dpi    = 300,
  bg     = page
)

image <- image_read('week_35.png')
image <- image_scale(image, geometry_size_pixels(width = 500, preserve_aspect = TRUE))
image_write(image, 'week_35_thumb.png', format = 'png')
Back to top