---
title: Тайлы
---

import { Card, LinkCard } from "@astrojs/starlight/components";

В этой главе мы рассмотрим

- понятие тайлов
- PostgreSQL + PostGIS
- Martin
- картографирование с помощью векторных тайлов

В рамках практической части создадим карту ойконимов Московского региона по векторным тайлам, генерирующимся на лету приложением Martin из базы данных PostGIS. При желании посмотрите [полный код](https://github.com/gtitov/martin-maplibre-map) и [возможный результат](https://gtitov.github.io/martin-maplibre-map/).

## Что такое тайлы

В прошлом упражнении мы сами разработали бэкенд для передачи пространственных данных из базы в браузер пользователю. Чаще для таких операций пользуются готовыми инструментами и устоявшимися подходами. Одним из таких подходов является применение тайлов.

![alt text](../../assets/image-18.png)

Пространственные данные могут быть большими по объёму. Если пользователь хочет посмотреть веб-карту, передавать ему все данные сразу (а это могут быть гигабайты) не нужно. Это приведёт к длительной загрузке веб-страницы, избыточному трафику, медленной работе веб-карты или падению браузера.

Данные можно поделить на кусочки и передавать пользователю только *нужные кусочки* с *нужной детальностью*. Данные можно поделить на кусочки по-разному. Пространственные данные ожидаемо удобно делить на географические кусочки — тайлы. *Нужные кусочки* -- те, что попадают на экран. *Нужная детальность* -- та, что соответствует текущему масштабу карты.

Стандартная система тайлов делит планету на квадраты X/Y для каждого уровня зума Z. Каждый тайл имеет индекс Z/X/Y. По этому индексу и выполняются запросы тайлов. Так формируется API сервиса векторных тайлов. Когда запрос выполняется, вызывается серверная функция, формирующая тайл, или возвращается заранее рассчитанный (кэшированный) тайл.

![zxy](../../assets/zxy.png)
*[By AsPJT](https://commons.wikimedia.org/w/index.php?curid=149301346)*

Тайл привязан к глобальной системе координат одной точкой. Геометрии внутри тайла хранятся во внутренней системе координат тайла. Тайлы бывают векторными и растровыми. В векторных тайлах содержание одного тайла составляют точки, линии и полигоны, [особым образом](https://docs.mapbox.com/data/tilesets/guides/vector-tiles-standards/#encoding-attributes) кодируются атрибуты. В растровых тайлах содержание одного тайла составляют пиксели.

Растровые тайлы можно использовать как для растровой модели данных (снимков, ЦМР, индексных изображений), так и для векторной, когда на тайлы будет нарезаться подготовленное изображение карты. Векторные тайлы оказываются удачным решением для векторных наборов данных.

<LinkCard title="Тайлы растровые и векторные" href="/extra/tiles" description="Что лучше 🤨"/>

## Использование тайлов

Познакомимся c возможностями практического применения тайлов. Для этого сделаем следующее.

1. Загрузим наборы пространственных данных в базу пространственных данных.
1. Подключим к ней сервер векторных тайлов.
1. Получим векторные тайлы на клиентской стороне веб-приложения средствами картографической JavaScript-библиотеки.

### База пространственных данных

Используем сервер баз данных Postgres с расширением для пространственных данных PostGIS.

<LinkCard title="Postgres + PostGIS = ♥" href="/extra/postgis" description="Решение для хранения пространственных данных"/>

#### Установка

Дистрибутив для сервера баз данных загрузим [с сайта PostgreSQL](https://www.postgresql.org/download/). При установке следует обратить внимание на порт, который будет занимать сервер баз данных, и пароль для пользователя `postgres`.

> Сервер баз данных запускается локально. Доступ к локальному серверу баз данных осуществляется по заданному при установке порту -- обычно 5432. Если установщик предлагает другой порт, возможно, сервер баз данных уже установлен на компьютер и занимает этот порт.

После установки сервера баз данных Postgres можно установить расширение для работы с пространственными данными PostGIS. Дистрибутив доступен [на сайте расширения](https://postgis.net/documentation/getting_started/#installing-postgis).

> На Windows наблюдаются проблемы с установкой PostGIS через StackBuilder. Рабочим вариантом является самостоятельная загрузка дистрибутива PostGIS [с сайта OSGeo](https://download.osgeo.org/postgis/windows/). Найдите папку с версией Postgres, которую установили. Например, для Postgres 17 нужна папка `pg17/`, в папке находится дистрибутив `pg17/postgis-bundle-pg17x64-setup-3.5.0-2.exe`.

#### Создание базы данных

Вместе с сервером базы данных устанавливается графический интерфейс для работы с базами данных PgAdmin 4.

![alt text](../../assets/image.png)

> При необходимости можно загрузить [только дистрибутив PgAdmin](https://www.pgadmin.org/download/) и установить графическую оболочку отдельно от сервера баз данных.

Через этот интерфейс мы создадим базу данных на нашем локальном сервере.

![alt text](../../assets/image-1.png)

Назовём базу данных `oikonyms`. Остальные параметры оставим по умолчанию.

![alt text](../../assets/image-2.png)

И добавим к созданной базе расширение `postgis`.

![alt text](../../assets/image-3.png)

![alt text](../../assets/image-4.png)

База пространственных данных создана. Пора её наполнить!

#### Загрузка данных

Грузить в базу будем данные об ойконимах Московского региона: 
1. точки -- [скачать](https://disk.yandex.ru/d/nYXeBHLxJ0yv-w)
2. агрегирующие их шестиугольники -- [скачать](https://disk.yandex.ru/d/UZZy0xkSuO94-Q)

> Здесь используем данные, которые Яндекс опубликовал в рамках [интересного исследования 2021 года](https://yandex.ru/company/researches/2021/oikonyms) о населённых пунктах на карте России.

Сделаем это через [QGIS](https://www.qgis.org/download/).

![alt text](../../assets/image-5.png)

Добавим слои на карту.

![alt text](../../assets/image-6.png)

Выполним подключение к базе данных.

![alt text](../../assets/image-7.png)

Назовём подключение `oikonyms-database`. Наша база размещена на локальном сервере `localhost`, занимает порт `5432`, а называется `oikonyms`. Используем базовую аутентификацию (Authentication - Basic), где укажем логин и пароль.

![alt text](../../assets/image-8.png)

Увидим нашу базу данных в списке подключений. Словом `public` обозначена схема базы данных, которую мы будем заполнять. Она создаётся для базы данных по умолчанию.

![alt text](../../assets/image-9.png)

Перенесём слои с карты в базу данных. Это можно сделать прямо из списка слоёв.

![alt text](../../assets/image-10.png)

Чтобы проверить, что данные успешно загружены в базу, выполним следующие действия:

1. Обновим подключение `oikonyms-database`, нажав на него правой кнопкой мыши и выбрав в контекстном меню опцию "Обновить"
1. В схеме данных `public` найдём загруженный набор пространственных данных
1. Перенесём его на карту и удостоверимся, что геометрия и атрибуты отображаются корректно

### Векторные тайлы

Мы не будем обращаться к базе данных напрямую. Мы добавим прослойку, которая будет формировать векторные тайлы из пространственных данных, хранящихся в базе.

#### Сервер векторных тайлов

Для подготовки векторных тайлов используем сервер векторных тайлов Martin. Загрузим это приложение [со страницы релиза](https://github.com/maplibre/martin/releases/tag/v0.14.2).

Распакуем архив и запустим сервер векторных тайлов, указав подключение к локальной базе данных, куда мы загрузили ойконимы. Чтобы это сделать, надо запустить терминал в папке, где распакован исполняемый файл Martin, например, `martin.exe`. И выполнить команду, которая в общем виде будет выглядеть так.

```sh
martin postgresql://postgres:password@localhost:5432/oikonyms
```

А в Windows приобретёт такой вид.

![alt text](../../assets/image-11.png)

После успешного выполнения увидим это.

![alt text](../../assets/image-12.png)

На последней строчке увидим, что каталог слоёв доступен по адресу `http://0.0.0.0:3000/catalog`, то есть по IP-адресам компьютера. Мы воспользуемся нашим любимым `localhost` (другие обычно и недоступны).

Перейдём по адресу `localhost:3000/catalog`, чтобы увидеть доступные наборы векторных тайлов. Их должно быть два по количеству пространственных таблиц в базе данных.

По адресам `localhost:3000/grid` и `localhost:3000/oikonyms` доступны описания наборов векторных тайлов в формате [TileJSON](https://github.com/mapbox/tilejson-spec/tree/master/3.0.0). Наиболее существенным в нём является указание адреса, по которому доступны векторные тайлы -- `localhost:3000/grid/{z}/{x}/{y}`

> `/catalog`, `/grid`, `/grid/{z}/{x}/{y}` -- это всё эндпоинты API, которые для нас автоматически формирует Martin. Он же выполняет нужные серверные функции, за счёт которых мы получаем ответы, обращаясь к этим эндпоинтам. И ничего не пришлось писать самим, как в прошлом упражнении!

При обращении `{z}/{x}/{y}` заменяются на индекс запрашиваемого тайла, например, `0/0/0` для запроса тайла на весь мир при уровне зума `0` или `1/1/0` для тайла в верхнем правом углу при уровне зума 1. Некоторые тайлы могут быть пустыми.

Когда тайл запрашивается, сервер векторных тайлов выполняет серверную функцию, которая вырезает соответствующий кусочек из исходного набора пространственных данных. Сервер векторных тайлов Martin обеспечивает подготовку тайла за счёт запроса к базе данных, то есть вырезанием и кодированием атрибутов и геометрии объектов, попадающих в тайл, занимается PostGIS.

<LinkCard title="Производительность векторных тайлов" href="/extra/vector-tile-performance" description="Как быстро PostGIS нарезает тайлы"/>

#### Векторные тайлы на карте

Остаётся принять эти векторные тайлы на карте. Заготовку для карты формируем как обычно.

Добавляем источник пространственных данных и картографический слой. При добавлении источника пространственных данных указываем тип `vector`, а не `geojson`, как в прошлых картах.

При добавлении слоя указываем `source-layer` -- векторный тайл может содержать несколько слоёв. В нашем случае слой только один, посмотреть на него можно в TileJSON-описании `localhost:3000/grid`, где идентификаторы слоёв указываются в обязательном списке `vector_layers`.

![alt text](../../assets/image-13.png)

```js title=main.js {2, 8}
map.addSource("grid", {
    type: "vector",
    url: "http://localhost:3000/grid",
})
map.addLayer({
    id: "grid-layer",
    source: "grid",
    "source-layer": "grid",
    type: "fill",
    paint: {}
})
```

При добавлении источника вместо `url` TileJSON-файла мы можем указать `tiles` -- список адресов, по которому можно выполнять запросы к тайлам. Если мы указываем `url`, MapLibre самостоятельно находит этот список в TileJSON.

![alt text](../../assets/image-14.png)

```js title=main.js {3}
map.addSource("oikonyms", {
    type: "vector",
    tiles: ["http://localhost:3000/oikonyms/{z}/{x}/{y}"],
})
map.addLayer({
    id: "oikonyms-layer",
    source: "oikonyms",
    "source-layer": "oikonyms",
    type: "circle",
    paint: {}
})
```

### Веб-карта

Подключили источники данных. Теперь пора заняться визуализацией.

#### Оформление слоёв

Слой с сеткой шестиугольников раскрасим интерполяцией цвета по полю численности населения в ячейке.

> Обратите внимание, поле `sum_pop` внутри карты является текстом, хотя в базе и векторном тайле указан числовой формат. Такое бывает. Проверить объект внутри карты всегда можно функцией `map.on("click", "grid-layer", (e) => console.log(e.features))`

```js title=main.js
map.addLayer({
    id: "grid-layer",
    source: "grid",
    "source-layer": "grid",
    type: "fill",
    paint: {
        "fill-color": [
            "interpolate", ["linear"],
            ["to-number", ["get", "sum_pop"]],
            0, "#440154",
            100, "#39568c",
            1000, "#1f968b",
            10000, "#fde725"
        ]
    }
})
```

Слой с точками населённых пунктов покажем кружками с обводкой.

```js title=main.js
map.addLayer({
    id: "oikonyms-layer",
    source: "oikonyms",
    "source-layer": "oikonyms",
    type: "circle",
    paint: {
        "circle-color": "#1a9641",
        "circle-radius": 6,
        "circle-stroke-width": 1,
        "circle-stroke-color": "#FFF",
        "circle-opacity": 0.8
    }
})
```

#### Мультимасштабное содержание

На картах можно менять уровень приближения. Картограф должен озаботиться тем, чтобы содержание на каждом уровне было визуально понятным и приятным.

Точки ойконимов в мелком масштабе накладываются друг на друга и закрывают шестиугольники. Не будем показывать их до 9-го уровня зума.

```diff title=main.js
map.addLayer({
    id: "oikonyms-layer",
    source: "oikonyms",
    "source-layer": "oikonyms",
    type: "circle",
    paint: {
        "circle-color": "#1a9641",
        "circle-radius": 6,
        "circle-stroke-width": 1,
        "circle-stroke-color": "#FFF",
        "circle-opacity": 0.8
    },
+    minzoom: 9    
})
```

Также ограничим возможности максимального отдаления и приближения веб-карты. Другими словами, установим минимальный и максимальных масштаб карты. При этом наибольшую степень приближения зафиксируем как максимальный зум `maxZoom`, а наибольшую степень отдаления как максимальный охват карты `maxBounds`.

```diff title=main.js
const map = new maplibregl.Map({
    container: "map",
    style: "https://raw.githubusercontent.com/gtitov/basemaps/refs/heads/master/voyager-nolabels.json",
    center: [37, 55],
    zoom: 6,
+    maxZoom: 11,
+    maxBounds: [[25, 50], [50, 60]],
    hash: true,
})
```

{/* О свойствах minzoom, maxzoom для карты, источника и слоя */}

#### Подлёт при клике

Подскажем пользователю, что содержание карты является мультимасштабным.

При клике на ячейку сетки подлетим к точке клика на 10 уровень зума, при котором отображаются ойконимы внутри ячейки. Чтобы подчеркнуть доступное интерактивное действие, используем изменение курсора.

```js title=main.js
map.on("click", "grid-layer", (e) => {
    map.flyTo({
        center: e.lngLat,
        zoom: 10
    })
})

map.on("mouseenter", "grid-layer", () => {
    map.getCanvas().style.cursor = "pointer"
})

map.on("mouseleave", "grid-layer", () => {
    map.getCanvas().style.cursor = ""
})
```

#### Подсветка при наведении

Изменение курсора при наведении на объект намекает на доступное интерактивное действие. Но ещё лучше его наличие подчеркнёт подсветка объекта при наведении курсора. Воспользуемся вариантом подсветки объекта с помощью его обводки.

Сначала зафиксируем объект, на котором находится курсор.

Назначим поле `id` из свойств слоя идентификатором объектов слоя.

```diff title=main.js
map.addSource("grid", {
    type: "vector",
    url: "http://localhost:3000/grid",
+    promoteId: "id"
})
```

Присвоим объекту состояние `hover: true`, когда курсор попадает на объект, и `hover: false`, когда курсор переходит на другой объект или покидает слой.

```js title=main.js
let hoveredFeatureId = null;

map.on("mousemove", "grid-layer", (e) => {
    if (hoveredFeatureId !== null) {
        // последнему назначенному объекту
        // присваиваем состояние `false`
        map.setFeatureState(
            {
                source: "grid",
                sourceLayer: "grid",
                id: hoveredFeatureId
            },
            { hover: false }
        )
    }
    // назначаем текущий объект
    hoveredFeatureId = e.features[0].id 
    // текущему объекту присваиваем состояние `true`
    map.setFeatureState(
        {
            source: "grid",
            sourceLayer: "grid",
            id: hoveredFeatureId
        },
        { hover: true }
    )
})

// когда курсор покидает слой
map.on("mouseleave", "grid-layer", () => {
    // последнему назначенному объекту
    // присваиваем состояние `false`
    map.setFeatureState(
        {
            source: "grid",
            sourceLayer: "grid",
            id: hoveredFeatureId
        },
        { hover: false }
    )
})
```

На основе состояния объекта делаем обводку.

> Объекты, которые курсор не трогал, не будут иметь состояния `hover` вообще. Для таких объектов через выражение `boolean` мы присваиваем значение `false`, чтобы они корректно обработались в выражении `case`.

```diff title=main.js
map.addLayer({
    id: "grid-layer",
    source: "grid",
    "source-layer": "grid",
    type: "fill",
    paint: {
        "fill-color": [
            "interpolate",
            ["linear"],
            ["to-number", ["get", "sum_pop"]],
            0,
            "#440154",
            100,
            "#39568c",
            1000,
            "#1f968b",
            10000,
            "#fde725"
        ],
+        "fill-outline-color": [
+            "case",
+            ["boolean", ["feature-state", "hover"], false],
+            "cyan",
+            "transparent"
+        ]
    }
})
```

Изменение курсора и подсветка ячейки ясно указывают пользователю, что на ячейку можно кликнуть и что-то произойдёт. В нашем случае, мы подлетим к карте до уровня видимости ойконимов.

#### Попап при наведении

При наведении на пунсоны ойконимов выведем их названия в попап.

```js title=main.js
const popup = new maplibregl.Popup({
    closeButton: false,
    closeOnClick: false
});

map.on("mouseenter", "oikonyms-layer", (e) => {
    popup
        .setLngLat(e.features[0].geometry.coordinates)
        .setHTML(e.features[0].properties.name)
        .addTo(map);
});

map.on("mouseleave", "oikonyms-layer", () => {
    popup.remove();
});
``` 

#### Интерактивная фильтрация

При использовании векторных тайлов можно организовать интерактивную фильтрацию.

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

```diff title=index.html
<body>
  <div id="map"></div>
+  <input type="number" id="filter" value="12000000"/>
+  <label for="filter">Фильтр по населению</label>
  <script src="main.js"></script>
</body>
```

```diff title=style.css
#map {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
+    z-index: -1;
}
```

И запрограммируем реакцию карты на ввод значений.

```js title=main.js
document.getElementById("filter").addEventListener("input", (e) => {
    filterValue = parseInt(e.target.value)
    map.setFilter("grid-layer", ["<", ["to-number", ["get", "sum_pop"]], filterValue])
})
```

## Что мы получили

Получилась просто отличная карта!

При желании посмотрите [полный код](https://github.com/gtitov/martin-maplibre-map) и [возможный результат](https://gtitov.github.io/martin-maplibre-map/).

Повторим основные моменты работы веб-карты, использующей векторные тайлы:

1. Пользователь открывает веб-карту при определённом зуме в определённом охвате.
1. Картографическая библиотека определяет, какие тайлы попадают в охват при заданном зуме.
1. Картографическая библиотека запрашивает векторные тайлы, выполняя запрос к API сервера векторных тайлов.
1. Сервер векторных тайлов получает запрос и выполняет серверную функцию, отвечающую за формирование тайла. Входным параметром для этой функции является индекс тайла Z/X/Y.
1. Серверная функция выполняет запрос к базе данных. Этот запрос вырезает из слоя пространственных данных объекты, попадающие в тайл, кодирует их атрибуты и геометрию.
1. Тайлы приходят в клиентскую часть картографического веб-приложения, где картографическая библиотека извлекает нужный слой из тайлов, сшивает объекты, попадающие в несколько тайлов, отрисовывает пространственные данные как картографический слой.
1. С данными полученного слоя мы можем спокойно работать: показывать попап с атрибутами при клике на объект, менять курсор при наведении на слой, интерактивно фильтровать данные.

Такая карта применима в разнообразных задачах. Фокусом использования векторных тайлов является визуализация больших объёмов пространственных данных. На этом каркасе построены популярные картографические сервисы, подсистемы визуализации пространственных данных в аналитических платформах. Векторные тайлы -- это проверенный, функциональный инструмент, но для его успешного применения надо понимать, из каких частей состоит этот механизм и за что отвечает каждая из этих частей.

## Упражнения

1. Сделайте так, чтобы при наведении курсора на точку из слоя ойконимов она становилась немного больше
1. Добавьте на карту инструмент фильтрации населённых пунктов по первой букве названия
1. Добавьте обводку в 3 пикселя ячейке, на которую был выполнен клик. Сделайте так, чтобы обводка исчезала при зуме меньше 9.

## Контрольные вопросы

1. Какое программное обеспечение предоставляет API для запроса тайлов в нашей веб-карте?
1. За какой параметр отвечает индекс `Z` в схеме векторных тайлов `Z/X/Y`?
1. Из чего формируется геометрическая составляющая векторного тайла?
1. Какой тип источника применяется для использования векторных тайлов в MapLibre GL JS?
1. За что отвечает параметр `source-layer` в коде функций MapLibre GL JS?

## Чтение

1. TileJSON 3.0.0 / TileJSON [[↗]](https://github.com/mapbox/tilejson-spec/blob/master/3.0.0/README.md)
1. Vector tiles standards / Mapbox [[↗]](https://docs.mapbox.com/data/tilesets/guides/vector-tiles-standards/)

