22  Интерактивные карты с {leaflet}

22.1 Что такое Leaflet?

Leaflet – это популярная JavaScript-библиотека для создания интерактивных карт. Она проста в использовании и имеет множество плагинов. В R мы можем пользоваться ей через пакет leaflet, который выступает обёрткой: все настройки пишутся на R, а результат отображается в виде HTML-виджета (в RStudio Viewer, браузере или сохранённом HTML-файле).

22.2 Подготовка

library(tidyverse)
library(leaflet)
library(sf)

22.3 Данные

В этом уроке мы будем использовать два набора данных:

  1. Клады римских монет (файл byzcoins.Rdata) – информация о местах находок кладов. Данные собраны через сайт https://chre.ashmus.ox.ac.uk/, их можно забрать по ссылке. Мы собрали только клады, отнесенные к “византийскому” периоду, т.е. до 518 г. В этом году скончался император Анастасий, денежная реформа которого считается началом византийской чеканки.

  2. Датасет на основе “Географии” Страбона. Источник: проект Recogito. Recogito — это бесплатный, очень простой, но достаточно удобный онлайн-инструмент, в 2018 получивший приз как лучший DH-tool в Digital Humanities Awards 2018. Он позволяет работать в командах над аннотированием текстов. Аннотации можно затем экспортировать в различных форматах, в том числе в формате GeoJSON.

Загрузим византийские клады:

load("../data/byzcoins.Rdata")
byzcoins_clean <- byzcoins |> 
  rename(lat = `findspot latitude`,
         lon = `findspot longitude`) |> 
  select(id, `hoard name`,
         lat, lon,
         contains("opening"),
         contains("closing"),
         contains("type"), 
         link) |> 
  rename(type = `hoard type`,
         reign_e = `closing reign (earliest)`,
         reign_l = `closing reign (latest)`,
         opening_e = `opening year (earliest)`,
         opening_l = `opening year (latest)`, 
         name = `hoard name`) |> 
  filter(!is.na(lon), !is.na(lat)) 

Датасет с “Географией” скачан с сайта https://recogito.pelagios.org (нужна регистрация); можно забрать из репозитория курса. Мы отберем только полигоны и мультиполигоны.

strabo_sf <- st_read("../files/strabo.json") |> 
  filter(st_geometry_type(geometry) %in% c("POLYGON", "MULTIPOLYGON"))
Reading layer `strabo' from data source 
  `/Users/olga/R_Workflow/TEXT_ANALYSIS_R/text_analysis_2024/files/strabo.json' 
  using driver `GeoJSON'
Simple feature collection with 1446 features and 2 fields
Geometry type: GEOMETRY
Dimension:     XY, XYZ
Bounding box:  xmin: -171.8469 ymin: -20 xmax: 172.162 ymax: 62.5
z_range:       zmin: 0 zmax: 0
Geodetic CRS:  WGS 84
strabo_sf <- strabo_sf |> 
  filter(st_is_valid(strabo_sf))

22.4 Первая карта: подложки

leaflet() |> 
  addTiles()

Функция leaflet() создаёт пустую карту, addTiles() добавляет стандартные тайлы OpenStreetMap. Это самый простой способ получить карту.

Но подложек гораздо больше! Провайдеры предлагают спутниковые снимки, рельеф, тематические карты. Полный список можно посмотреть на <leaflet-extras.github.io/leaflet-providers/preview/index.html>.

leaflet() |> 
  addProviderTiles("Esri.WorldImagery", # Спутник Esri
                   options = providerTileOptions(opacity = 0.8))

Некоторые подложки (например, Stamen Watercolor) требуют регистрации на сайте провайдера и указания домена, но локально они обычно работают без ключа. Для публикации карты на сайте лучше использовать те, которые не требуют аутентификации, либо получить бесплатный ключ (например, на stadiamaps.com).

На заметку

Можно менять прозрачность подложки или накладывать несколько слоёв с помощью addProviderTiles(..., options = providerTileOptions(opacity = 0.5))

Задание

Добавьте на карту масштабную линейку и поместите ее в левый нижний угол.

22.5 Добавляем объекты

22.5.1 Точки

Самый простой способ показать точки – addCircles():

byzcoins_clean |> 
  leaflet() |> 
  addProviderTiles("Esri.WorldImagery") |> 
  addCircles(lng = ~lon, lat = ~lat) 

Параметры lng и lat указывают, в каких колонках находятся координаты. Символ ~ перед именем колонки – это формула, означающая «возьми эту переменную из данных».

22.5.2 Маркеры

Маркеры (значки) добавляются через addMarkers():

byzcoins_clean |> 
  leaflet() |> 
  addProviderTiles("Esri.WorldImagery") |> 
  addMarkers(lng = ~lon, lat = ~lat)

Если точек много, они могут накладываться друг на друга. Для этого есть кластеризация:

byzcoins_clean |> 
  leaflet() |> 
  addProviderTiles("Esri.WorldImagery") |> 
  addMarkers(lng = ~lon, lat = ~lat,
             clusterOptions = markerClusterOptions())

22.5.3 Иконки

Можно использовать собственные изображения.

my_icon <- makeIcon(
  iconUrl = "./images/coin.jpg",
  iconWidth = 31,   
  iconHeight = 31,
  iconAnchorX = 31/2, # точка привязки (центр по X)
  iconAnchorY = 31    # низ иконки
)

byzcoins_clean |> 
  leaflet() |> 
  addProviderTiles("Esri.WorldTerrain") |> 
  addMarkers(icon = ~my_icon,
             lng = ~lon, lat = ~lat,
             clusterOptions = markerClusterOptions())

iconAnchorX/Y определяют, какая точка иконки будет точно соответствовать координатам (обычно центр низа).

22.6 Всплывающие окна и подписи

Интерактивность карты раскрывается, когда при клике или наведении появляется информация об объекте.

22.6.2 Label

Лейбл появляется при наведении мыши (без клика). Добавим его к маркерам:

byzcoins_clean |> 
  leaflet() |> 
  addProviderTiles("Esri.WorldImagery") |> 
  addMarkers(lng = ~lon, lat = ~lat,
             label = ~name,
             clusterOptions = markerClusterOptions())
Задание

Сделайте всплывающее название клада гиперссылкой на его страницу (столбец link).

22.7 Цвета и легенда

Цветом можно кодировать дополнительные переменные. Например, тип клада.

Сначала создадим палитру – функцию, которая по значению типа или имени правителя возвращает цвет. Для дискретных значений удобно использовать colorFactor:

library(RColorBrewer)
factpal <- colorFactor(
  palette = brewer.pal(n = n_distinct(byzcoins_clean$type), name = "Set1"),
  domain = byzcoins_clean$type
)

Теперь применим её в addCircles через параметр color:

byzcoins_clean |> 
  leaflet() |> 
  addProviderTiles("Esri.WorldPhysical") |> 
  addCircleMarkers(lng = ~lon, lat = ~lat,
             color = ~factpal(type),
             fillOpacity = 0.7,
             radius = 6,
             popup = ~paste0("<b>", name, "</b><br>",
                             "Дата: ", opening_e, "–", opening_l,
                             "<br>Правитель: ", reign_e))

Чтобы добавить легенду, используем addLegend():

byzcoins_clean |> 
  leaflet() |> 
  addProviderTiles("Esri.WorldPhysical") |> 
  addCircleMarkers(lng = ~lon, lat = ~lat,
             color = ~factpal(type),
             fillOpacity = 0.7,
             radius = 6,
             popup = ~paste0("<b>", name, "</b><br>",
                             "Дата: ", opening_e, "–", opening_l,
                             "<br>Правитель: ", reign_e)) |> 
  addLegend(position = "bottomright",
            pal = factpal,
            values = ~type,
            title = "Тип клада",
            opacity = 1)
Задание

Закодируйте цветом правителей, при которых начал пополняться клад. От какого императора осталось больше всего кладов?

22.8 Работа с полигонами

Иногда нужно показать не точки, а области или маршруты. Для этого есть addPolygons() и addPolylines(). Данные должны быть в формате SpatialPolygonsDataFrame или sf.

leaflet(strabo_sf) |> 
  addProviderTiles("Esri.WorldPhysical") |> 
  addPolygons(popup = ~titles)

Можно покрасить области по какому-нибудь показателю (например, населению). Для этого у нас есть только столбец с числом аннотаций. За неимением лучшего используем его:

# Создаём палитру (например, от жёлтого к красному)
pal <- colorNumeric(
  palette = "YlOrRd",      # можно также "viridis", "Blues" и др.
  domain = strabo_sf$annotations
)

# Рисуем карту
leaflet(strabo_sf)  |> 
  addProviderTiles("Esri.WorldPhysical") |>   
  addPolygons(
    fillColor = ~pal(annotations),
    fillOpacity = 0.7,
    weight = 1,
    color = "white",
    popup = ~paste0("<b>", titles, "</b><br>",
                    "Аннотаций: ", annotations)
  )  |> 
  addLegend(
    position = "bottomright",
    pal = pal,
    values = ~annotations,
    title = "Число аннотаций",
    opacity = 0.7
  )

Также для полигонов доступен highlightOptions(), который подсвечивает объект при наведении.

# Рисуем карту
leaflet(strabo_sf)  |> 
  addProviderTiles("Esri.WorldPhysical")  |> 
  addPolygons(
    fillColor = ~pal(annotations),
    fillOpacity = 0.7,
    highlightOptions = highlightOptions(
        weight = 3,
        color = "darkred",
        fillOpacity = 0.9,
        bringToFront = TRUE
    ),
    weight = 1,
    color = "white",
    popup = ~paste0("<b>", titles, "</b><br>",
                    "Аннотаций: ", annotations)
  )  |> 
  addLegend(
    position = "bottomright",
    pal = pal,
    values = ~annotations,
    title = "Число аннотаций",
    opacity = 0.7
  ) 

22.9 Управление слоями

Если на карте несколько групп объектов, удобно дать пользователю возможность включать/выключать их. Для этого используется addLayersControl.

leaflet() |> 
  addProviderTiles("Esri.WorldTopoMap") |> 
  addCircles(data = byzcoins_clean, lng = ~lon, lat = ~lat,
             group = "Монеты", color = "red",
             popup = ~name) |> 
  addPolygons(data = strabo_sf, 
             group = "Страбон", color = "blue",
             fillOpacity = 0.3) |> 
  addLayersControl(overlayGroups = c("Монеты", "Страбон"),
                   options = layersControlOptions(collapsed = FALSE))

Теперь можно скрывать или показывать каждый слой отдельно.

22.10 Сохранение карты

Интерактивную карту можно сохранить как отдельный HTML-файл и поделиться с коллегами или встроить в веб-страницу.

library(htmlwidgets)
my_map <- leaflet(byzcoins_clean) |> 
  addProviderTiles("Esri.WorldTopoMap") |>  
  addCircles(lng = ~lon, lat = ~lat)
saveWidget(my_map, file = "coins.html")

22.11 Расширения

library(leaflet.extras2)
library(yyjsonr)
library(sf)
library(leaflet)
library(leaflet.extras2)
library(dplyr)

# 1. Подготовка данных: делаем колонку 'time_str' в формате "YYY-MM-DD"
coins_prepared <- byzcoins_clean  |> 
  filter(!is.na(opening_e))  |> 
  mutate(
    # Слайдер лучше понимает текст в формате даты
    time_str = sprintf("%03d-01-01", as.integer(opening_e))
  )  |> 
  st_as_sf(coords = c("lon", "lat"), crs = 4326)

leaflet()  |> 
  addTiles()  |> 
  addTimeslider(
    data = coins_prepared,
    radius = 5,
    color = "darkred",
    fillOpacity = 0.8,
    popup = ~name,
    options = timesliderOptions(
      timeAttribute = "time_str",    # Используем строковую дату
      timeStrLength = 3,
      range = TRUE,
      showAllOnStart = TRUE,
      position = "topright"
    )
  )

22.12 Домашнее задание

📌 Ссылка на GitHub Classroom: https://classroom.github.com/a/-7pIMKNx

📊 Для этого домашнего задания для вас подготовлен датасет на основе открытых данных Министерства культуры РФ за 2023 г. (Библиотеки, Библиотеки. Сводные данные. Статистическая информация) и Росстата (Численность постоянного населения в среднем в год). На основе этих данных было рассчитано число библиотек на 1000 человек населения по формуле: ( число библиотек / население ) * 1000 (округление до тысячных). В таблице это столбец density.

NB: В исходном датасете Минкульта представлена статистика только по 85 регионам. Данные об административных границах получены при помощи пакета {rusmaps}.

🔍 Код, который использовался для подготовки датасета, вы найдете в папке helper_scripts. В папке files лежат исходные файлы. Со всем этим ничего делать не надо, это для общего понимания (и самостоятельных экспериментов).

🛠 Вы берете только две готовые таблицы (файлы .Rdata). Они лежат в папке data. Ваша задача: используя библиотеку {leaflet}, максимально точно воспроизвести вот эту карту: https://rpubs.com/locusclassicus/libmap

🗺 На этот раз критерии не прописаны (но они у нас есть). Это часть замысла. Что такое «максимально точно», вам придется думать самим: это тоже важный навык. Картинка для иконки есть в папке. Код для построения карты вы оставляете в своем репозитории GitHub в файле под названием hw18.R.

🔗 Ссылку на готовую карту загружайте в форму по ссылке вместе с ФИО: https://forms.gle/2YuvkyE8sEYUUE5B8

🕓 Дедлайн: 5 апреля 2026, 23:59. Оценка 0-10.

💡 Удачи в выполнении задания и приятного кодинга!