Day 07 - Isochrones

Author

gnoblet

Published

November 7, 2025

Isochrones: Travel Time by Bike to Geneva’s Jet d’Eau

Let’s create isochrones showing travel time by bike to Geneva’s iconic Jet d’Eau using the osrm package for routing and sf for spatial operations.

# Libraries
library(sf)
library(dplyr)
library(osmdata)
library(osrm)
library(ggplot2)
library(showtext)
library(sysfonts)
library(ggtext)
library(patchwork)
library(ggbranding)
library(scales)

# Fonts
showtext_auto()
showtext_opts(dpi = 600)
font_add_google("Oswald", "oswald")

# Define Bain des Pâquis location (approximate coordinates)
bain_paquis <- data.frame(
  name = "Bain des Pâquis",
  lon = 6.1512,
  lat = 46.2107
) |>
  st_as_sf(coords = c("lon", "lat"), crs = 4326)

# Define Geneva bounding box
geneva_bb <- c(6.04, 46.15, 6.25, 46.27)

# Get streets for context
streets <- geneva_bb |>
  opq() |>
  add_osm_feature(
    key = "highway",
    value = c("motorway", "primary", "secondary", "tertiary")
  ) |>
  osmdata_sf()
streets <- streets$osm_lines

# Get smaller streets
streets_small <- geneva_bb |>
  opq() |>
  add_osm_feature(
    key = "highway",
    value = c("residential", "service", "unclassified")
  ) |>
  osmdata_sf()
streets_small <- streets_small$osm_lines

# Get water features
water <- geneva_bb |>
  opq() |>
  add_osm_feature("water", c("river", "lake")) |>
  osmdata_sf()
water_multipoly <- water$osm_multipolygons |>
  st_make_valid()

# Calculate isochrones from Bain des Pâquis
# Using bike profile with breaks at 6, 11, 16, 21, 31 minutes
isochrones <- osrmIsochrone(
  loc = bain_paquis,
  breaks = c(6, 11, 16, 21, 31),
  osrm.profile = "bike"
)

# Remove water areas from isochrones
sf_use_s2(FALSE)
water_union <- st_union(water_multipoly) |> st_make_valid()
isochrones <- st_difference(isochrones, water_union)
sf_use_s2(TRUE)
# Colors
bg_fill <- "#0b1220"
major_road <- "#21394bff"
minor_road <- "#1a2d3aff"
water_fill <- "#123f4f"
iso_colors <- c("#05f9d5ff", "#0ac9aaff", "#0f9980ff", "#146955ff", "#0b3f30ff")

# Main Plot
p <- ggplot() +
  # Water
  geom_sf(
    data = water_multipoly,
    fill = water_fill,
    color = NA,
    alpha = 0.6
  ) +
  # Smaller streets
  geom_sf(
    data = streets_small,
    color = minor_road,
    linewidth = 0.3,
    alpha = 0.6,
    lineend = "round"
  ) +
  # Major streets
  geom_sf(
    data = streets,
    color = major_road,
    linewidth = 0.5,
    alpha = 0.8,
    lineend = "round"
  ) +
  # Isochrones
  geom_sf(
    data = isochrones,
    aes(fill = factor(isomax)),
    color = "#ffffff",
    linewidth = 0.5,
    alpha = 0.4
  ) +
  # Bain des Pâquis location
  geom_sf(
    data = bain_paquis,
    color = "#ffffff",
    fill = "#ff3366",
    shape = 21,
    size = 4,
    stroke = 1.5
  ) +
  # Color scale
  scale_fill_manual(
    values = setNames(iso_colors, c("6", "11", "16", "21", "31")),
    labels = c("0-5 min", "6-10 min", "11-15 min", "16-20 min", "21-30 min"),
    name = "Travel Time by Bike"
  ) +
  # Coordinates
  coord_sf(
    xlim = c(geneva_bb[1], geneva_bb[3]),
    ylim = c(geneva_bb[2], geneva_bb[4]),
  ) +
  # Theme
  theme_void() +
  theme(
    plot.background = element_rect(fill = bg_fill, color = NA),
    panel.background = element_rect(fill = bg_fill, color = NA),
    legend.position = c(0.15, 0.77),
    legend.title = element_text(
      family = "oswald",
      color = "#ffffff",
      size = 18,
      face = "bold",
      margin = margin(b = 12)
    ),
    legend.text = element_text(
      family = "oswald",
      color = "#ffffff",
      size = 16
    ),
    legend.background = element_rect(
      fill = alpha(bg_fill, 0.7),
      color = NA,
      linewidth = 0.5
    ),
    legend.key = element_rect(color = NA),
    legend.key.spacing.y = unit(8, "pt"),
    legend.margin = margin(12, 12, 12, 12)
  )

# Title
title <- paste0(
  "<span style='font-size:30pt; line-height:0.8; color:#ffffff; font-weight:700'>",
  "How far can you bike in ",
  "<span style='color:#05f9d5ff'>30 minutes</span>",
  " from ",
  "<span style='color:#ff3366'>Bain des Pâquis</span>?",
  "</span>"
)

# Subtitle
subtitle <- paste0(
  "<span style='font-size:20pt; color:#afc6c0ff'>",
  "Isochrones showing cycling travel time in Geneva | Data: OpenStreetMap & OSRM",
  "</span>"
)

# Branding
branding <- branding(
  github = "gnoblet",
  bluesky = "gnoblet.bsky.social",
  website = "guillaume-noblet.com",
  text_family = "oswald",
  text_color = "white",
  icon_color = "#05f9d5ff",
  text_size = "16pt",
  icon_size = "16pt"
)

# Add title and branding as insets
p <- p +
  inset_element(
    ggplot() +
      # Title
      geom_textbox(
        aes(label = title),
        family = "oswald",
        x = 0.5,
        y = 0.12,
        hjust = 0.5,
        valign = 0.5,
        halign = 0.5,
        vjust = 1,
        fill = bg_fill,
        alpha = 0.5,
        box.color = "#ffffff",
        box.padding = unit(c(12, 12, 12, 12), "pt"),
        width = unit(0.8, "npc")
      ) +
      # Subtitle
      geom_textbox(
        aes(label = subtitle),
        family = "oswald",
        x = 0.5,
        y = 0.02,
        hjust = 0.5,
        halign = 0.5,
        vjust = 1,
        fill = NA,
        box.color = NA,
        width = unit(0.8, "npc")
      ) +
      # Branding
      geom_textbox(
        aes(label = branding),
        x = 0.5,
        y = 0.98,
        family = "oswald",
        fill = NA,
        box.color = NA,
        hjust = 0.5,
        vjust = 0,
        width = unit(0.9, "npc")
      ) +
      theme_void(),
    left = 0,
    right = 1,
    bottom = 0,
    top = 1,
    align_to = 'full'
  )

# Save plot
ggsave("day_07.png", plot = p, width = 12, height = 10, dpi = 600)
Back to top