Всем привет! Мы рассмотрим библиотеку для построения карт OpenLayers (версии 8.2.х). Вы узнаете о том, какие возможности она предоставляет, как ей пользоваться и почему в команде НСПД мы выбрали именно её. В статье будет много примеров кода, которые также доступны на GitHub и StackBlitz.

Для чтения статьи необходимо иметь хотя бы базовые знания HTML, CSS и JavaScript; иметь представление о сборщиках кода (в примерах использую Vite).

Приятного чтения! ?


Что такое OpenLayers?

«A high-performance, feature-packed library for all your mapping needs»
«A high-performance, feature-packed library for all your mapping needs»

OpenLayers — библиотека для создания карт с открытым исходным кодом (ссылка на исходники кода). Написана на JavaScript, имеет хорошую поддержку Typescript. Является одним из популярнейших решений для работы с картами, конкурируя с Leaflet.

Подход OpenLayers состоит в том, чтобы предоставить пользователю весь необходимый функционал для работы с картами в одной библиотеке, с упором на быстродействие и удобство для конечного пользователя.

Почему OpenLayers?

OpenLayers любят за это:

  1. Быстродействие. Поддержка WebGL из коробки.

  2. Богатый функционал из коробки. Небольшой список я приведу ниже

  3. Архитектурная гибкость. Довольно просто расширять, настраивать под специфические нужды, проводить unit-тестирование.

  4. Отличная поддержка браузеров и девайсов (т.к. мобильные устройства, Retina-дисплей)

  5. Обилие примеров и документации. 240+ примеров

  6. Поддержка всех протоколов OGC, таких как WMF, WFS, WMTS, WPS.

  7. OpenSource. Исходники кода можно (и нужно) читать для лучшего понимания того, что происходит под капотом в Вашем приложении.

  8. Поддержка TreeShaking. В бандл идёт только то, что вы используете (импортируете).

  9. Поддержка любой проекции карты. В библиотеке указаны только EPSG:3857 и EPSG:4326, однако есть возможность добавить любую другую проекцию (пример).

Скрытый текст
  • Поддержка растровых и и векторных тайлов

    • Динамическая замена тайлов по зуму (разрешению)

  • Работа с векторной графикой по стандарту GeoJSON

  • Интерактивные взаимодействия с пользователем (Примеры), в том числе:

  • Стилизация

    • Возможность указать свою функцию рендера на канвасе (Пример)

    • Возможность стилизовать на разных уровнях: от всего слоя, до отдельной фичи

    • Возможность стилизовать части геометрии (Пример)

  • Возможность привязать любой HTML или SVG элемент к любому месту на карте по координатам 

  • Поддержка GeoTIFF

  • Поддержка D3.js

  • и многое другое

И всё это из коробки, из одного пакета npm!

Проработав с библиотекой несколько лет, выявилась и пара недостатков:

  • есть небольшие неудобства в типизации некоторых методов подписки на события (`.on`)

  • требует очистки памяти после окончания использования на каждом компоненте (issue #14583)

Мы, в команде НСПД, очень довольны этой библиотекой и рекомендуем к ознакомлению и использованию.

Принцип работы

Библиотека построена с на принципах ООП с добавлением событийно-ориентированного. Сам код представляет собой набор классов, которые общаются друг с другом посредством отправки событий.

Благодаря такому подходу, от пользователя скрыты несущественные детали реализации, пользователь библиотеки может легко декомпозировать свой код, сам подписаться на желаемые ему события и получать необходимые данные. Библиотеку просто тестировать.

Однако этот же подход может породить утечки памяти и порождает требование к конечному пользователю не забывать удалять связи между классами, вызывая публичный метод dispose() (issue #14583). Об этом я обязательно напишу отдельную небольшую статью: наша команда уже обожглась с этим моментом и хотелось бы поделиться этим опытом.

Лицензия

OpenLayers распространяется по лицензии FreeBSD или 2-clause BSD. Ссылка на полный текст лицензии.  Лицензия всего лишь обязывает сохранять уведомление об авторских правах и позволяет использовать её в коммерческой разработке без раскрытия исходного кода коммерческого продукта.


Практика

Для использования библиотеки необходим любой сборщик модулей, будь то Webpack, Vite, Rollup или любой другой. В своих примерах я буду использовать Vite, он прекрасен.

Для использования OpenLayers, достаточно установить npm пакет командой

npm install ol

Все примеры кода из статьи доступны в онлайн-редакторе StackBlitz, так что для того, чтобы их пощупать совершенно не обязательно устанавливать всё локально. Также весь исходный код примеров доступен на GitHub.

Примеры в онлайн-редакторе StackBlitz
Примеры в онлайн-редакторе StackBlitz

Создаём первую карту

Ссылки на Онлайн-пример и Исходный код.

Создадим простую карту с центром в Москве и тайлами от OpenStreetMap

HTML

<head>
  <!-- Стили по умолчанию от OpenLayers -->
  <link rel="stylesheet" href="/node_modules/ol/ol.css" />
  <!-- Наши стили: сделаем карту во всю ширину страницы -->
  <link rel="stylesheet" href="./style.css" />
</head>
<body>
  <!-- Контейнер для карты -->
  <div id="map"></div>
  <!-- JavaScript код для инициализации карты -->
  <script type="module" src="./index.js"></script>
</body>

Стили

html, body {
 margin: 0;
 height: 100%;
}
#map {
 position: absolute;
 top: 0;
 bottom: 0;
 width: 100%;
}

JavaScript

import { Map, View } from "ol";
import OSM from "ol/source/OSM";
import TileLayer from "ol/layer/Tile";

// Создаём экземпляр карты
const map = new Map({
 // HTML-элемент, в который будет инициализирована карта
 target: document.getElementById("map"),
  
 // Список слоёв на карте
 layers: [
   // Создадим тайловый слой. Источником тайлов будет OpenStreetMap
   new TileLayer({ source: new OSM() }),
 ],
  
 // Параметры отображения карты по умолчанию: координата центра и зум
 view: new View({
   center: [4190701.0645526173, 7511438.408408914],
   zoom: 10,
 }),
});
Так выглядит Москва в тайлах по умолчанию от OpenStreetMap
Так выглядит Москва в тайлах по умолчанию от OpenStreetMap

Каждый установленный параметр затем можно будет изменить в любой момент в ходе выполнения программы

// Можем изменить значения любых переданных параметров
map.setTarget(document.getElementById("#another-place-for-map"));
map.getView().setZoom(5);
map.getView().setCenter([4190701.0645526173, 7511438.408408914]);
// и так далее…

Изменим проекцию

Ссылки на Онлайн-пример и Исходный код.

По умолчанию, карта создается с проекцией EPSG:3857 (Web Mercator). Для того, чтобы изменить систему координат на, допустим, EPSG:4326 (WGS 84), следует обновить вызов View.

new View({
   projection: 'EPSG:4326',
   // ...
}),
Слева мы видим карту в проекции EPSG:3857, а справа - EPSG:4326
Слева мы видим карту в проекции EPSG:3857, а справа - EPSG:4326

Соответственно, изменяя проекцию, изменяется и система координат.

Так, например, если мы хотим центрировать карту на Москву для EPSG:3857 мы используем:

new View({
   // Москва в EPSG:3857 (метры)
   center: [4190701.0645526173, 7511438.408408914],
}),

, а для EPSG:4326:

new View({
   // Москва в EPSG:4326 (градусы)
   center: [37.645708, 55.7632972],
})

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

Подсказка: как получить координаты точки на карте?

Добавляя прослушку на событие клика, можно легко и быстро узнать координаты определённого места на карте. Напомню, что значение координаты зависит от системы координат используемой проекции.

// Добавляем обработчик клика по карте
map.on("click", function (event) {
 // Кидаем в консоль координаты
 console.log(event.coordinate);
 // Часто бывает полезно знать текущий зум
 console.log(map.getView().getZoom());
});

Подсказка: конвертация координат

Часто бывает так, что координаты могут быть в разных проекциях. Для удобства использования, OpenLayers предоставляет 2 функции для конвертации координат между EPSG:4326 и EPSG:3857:

import { fromLonLat, toLonLat } from "ol/proj";

// Конвертация координат из EPSG:4326 в EPSG:3857
// Вернёт: [4190701.0645526173, 7511438.408408914]
fromLonLat([37.64570817463565, 55.76329720561773]);

// И обратно
// Вернёт: [37.64570817463565, 55.76329720561773]
toLonLat([4190701.0645526173, 7511438.408408914]);

Тайловые слои

В предыдущем примере мы использовали OpenStreetMap, однако, OpenLayers позволяет использовать любой провайдер тайлов. Также добавлю, что библиотека поддерживает как растровые тайлы, так и векторные.

ArcGIS

Ссылки на Онлайн-пример и Исходный код.

Довольно часто приходится менять тайлы для наших приложений. Для примера, давайте добавим тайлы из сервиса ArcGIS.

import { Map, View } from "ol";
import XYZ from "ol/source/XYZ";
import TileLayer from "ol/layer/Tile";

const map = new Map({
 target: document.getElementById("map"),
 layers: [
   new TileLayer({
     source: new XYZ({
       attributions:
         'Tiles © <a href="https://services.arcgisonline.com/ArcGIS/' +
         'rest/services/World_Topo_Map/MapServer">ArcGIS</a>',
       url: "https://server.arcgisonline.com/ArcGIS/rest/services/" + "World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
     }),
   }),
 ],
 view: new View({
   center: [4517934.495704523, -2284501.936731553],
   zoom: 5,
 }),
});
Для примера используем тайлы из ArcGIS
Для примера используем тайлы из ArcGIS

OGC (растровые тайлы)

Ссылки на Онлайн-пример и Исходный код.

import OGCMapTile from 'ol/source/OGCMapTile.js';
import TileLayer from 'ol/layer/Tile.js';

new TileLayer({
  // API не подходит для использования XYZ?
  // Решение: создать свой подкласс и доработать API под свои нужды
  source: new OGCMapTile({
    url: 'https://maps.gnosis.earth/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad',
  }),
}),
Для примера используем тайлы от Gnosis (Blue Marble Next Generation)
Для примера используем тайлы от Gnosis (Blue Marble Next Generation)

OGC (векторные тайлы)

Ссылки на Онлайн-пример и Исходный код.

Преимущество векторных тайлов над растровыми в том, что они:

  • имеют меньший размер файла

  • быстрее загружаются

  • легко стилизуются

  • не размываются

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

import MVT from 'ol/format/MVT.js';
import OGCVectorTile from 'ol/source/OGCVectorTile.js';
import VectorTileLayer from 'ol/layer/VectorTile.js';

new VectorTileLayer({
  source: new OGCVectorTile({
    url: 'https://maps.gnosis.earth/ogcapi/collections/NaturalEarth:cultural:ne_10m_admin_0_countries/tiles/WebMercatorQuad',
    format: new MVT(),
  }),
  background: '#e2e3e3',
  style: {
    'stroke-width': 1,
    'stroke-color': '#8c8b8b',
    'fill-color': '#f7f7e9',
  },
})
Для примера используем тайлы от Gnosis
Для примера используем тайлы от Gnosis

Подробнее об этом можно прочесть здесь или в Workshop.

Используем дебаггер тайлов

Ссылки на Онлайн-пример и Исходный код.

import TileLayer from "ol/layer/Tile.js";
import TileDebug from "ol/source/TileDebug";

new TileLayer({ source: new TileDebug() })
Да, это просто сетка поверх основного тайла
Да, это просто сетка поверх основного тайла

Подробнее можно почитать здесь

Стилизация

Ссылки на Онлайн-пример и Исходный код.

const layer = new TileLayer({
 // Выбираем источник для тайлов
 source: new OGCMapTile({
   url: "https://maps.gnosis.earth/ogcapi/collections/blueMarble/map/tiles/WebMercatorQuad",
 }),
 // Можем сделать прозрачным
 opacity: 0.9,
 // Можем скрывать слой, не убирая его из карты (и теряя тем самым настройки)
 visible: true,
 // Можем менять наложение слоёв друг на друга
 zIndex: 5,
 // Можем ограничить слой определённой областью
 extent: [6717612.447527122, 583571.8523972307, 10985130.57817296, 3294022.5569966147],
 // Можем показывать слой лишь в заданных границах зума
 maxZoom: 7,
 // Можем так же показывать слой лишь на определённых разрешениях (масштабе) карты
 // minResolution: 10000,
 // maxResolution: 100000,
});
map.addLayer(layer);

Пример в видео (не удалось загрузить гифку, так как она больше 8мб ?).

Более того, мы можем использовать ещё и CSS стили для этого слоя. Именно для этого я прописал класс «favorite-layer».

const layer = new TileLayer({
 // Можем добавить CSS-стилей
 className: "favorite-layer",
 // ...
});
Каждый слой доступен в HTML
Каждый слой доступен в HTML

Так, например, можно добавить эффект размытия

.favorite-layer {
  filter: blur(1px);
}
CSS-эффект размытия, примененный к слою на карте
CSS-эффект размытия, примененный к слою на карте

Оверлей (Overlay)

Часто бывает, что нужно обычный HTML-элемент «привязать» к определенной координате на карте. 

Для этих целей существует класс `Overlay`. Он хранит в себе координаты позиции на карте и ссылку на HTML-элемент и их связывает. 

Добавляем попап с координатами по клику

В HTML файле мы добавляем такой шаблон

<!-- Шаблон попапа -->
<div style="display: none">
  <div id="popup" class="popup">
    <p>Координаты</p>
    <p><span id="coordinates"></span></p>
  </div>
</div>

В JS файле используем HTML-шаблон

// Попап (без указанного `position` он не будет показан)
const popup = document.getElementById("popup");
const overlay = new Overlay({
 element: popup,
 positioning: "bottom-center",
 offset: [0, -4],
});
map.addOverlay(overlay);


map.on("click", (event) => {
 // Переводим координаты в географические (широта, долгота)
 const [lon, lat] = toLonLat(event.coordinate);


 // Отображаем в формате "широта, долгота"
 const coordsEl = popup.querySelector("#coordinates");
 coordsEl.textContent = createStringXY(6)([lat, lon]);


 // Закрепляем оверлей за координатой
 overlay.setPosition(event.coordinate);
});
По клику по карте появляются координаты
По клику по карте появляются координаты

Отобразим офис компании, использовав маркер и попап

Довольно часто карту используют только для этих целей ?

HTML

<!-- Шаблон маркера -->
<template id="marker">
 <img width="36" src="/public/icons/marker.svg" alt="Marker" />
</template>


<!-- Шаблон попапа -->
<template id="popup">
 <div class="popup">
   <p>Головной офис БФТ-Холдинг</p>
   <p>129085, г. Москва, ст. м. «Алексеевская» ул. Годовикова, д. 9, стр. 17</p>
 </div>
</template>

JavaScript

// Позиция на карте (головной офис в БФТ)
const position = [4188878.742882752, 7520435.741484543];


// Маркер
const markerTemplate = document.getElementById("marker");
const marker = markerTemplate.content.cloneNode(true);
const markerOverlay = new Overlay({
 element: marker,
 positioning: "bottom-center",
 position: position,
});
map.addOverlay(markerOverlay);


// Попап
const popupTemplate = document.getElementById("popup");
const popup = popupTemplate.content.cloneNode(true);
const popupOverlay = new Overlay({
 element: popup,
 positioning: "bottom-center",
 offset: [0, -36],
 position: position,
});
map.addOverlay(popupOverlay);
Адрес офиса на карте с подсказкой
Адрес офиса на карте с подсказкой

Таким образом, в случаях, когда для одной позиции может быть привязано несколько HTML-элементов, мы можем использовать несколько оверлеев.

Кликабельные маркеры на примере аэропортов Москвы

Мне понравилась эта иконка, поэтому решил добавить этот пример ?

HTML

<div style="display: none">
 <!-- Шаблон маркера -->
 <img id="marker" class="marker" width="36" src="/public/icons/airport.svg" alt="Marker" />


 <!-- Шаблон попапа -->
 <div id="popup" class="popup">
   <p id="title"></p>
 </div>
</div>

JavaScript

// Попап
const popup = document.getElementById("popup").cloneNode(true);
const popupOverlay = new Overlay({
 element: popup,
 positioning: "bottom-center",
 offset: [0, -42],
});
map.addOverlay(popupOverlay);


// Функция создания маркеров
// По клику на маркер, покажется попап
function createMarker(position, title) {
 const marker = document.getElementById("marker").cloneNode(true);
 const markerOverlay = new Overlay({
   element: marker,
   positioning: "bottom-center",
   position: position,
 });
 map.addOverlay(markerOverlay);


 marker.addEventListener("click", function () {
   popupOverlay.setPosition(position);
   popup.querySelector("#title").textContent = title;
 });
}


// Создание маркеров аэропортов
// Аэропорты хранятся в .json файле
airports.forEach((airport) => {
 createMarker(airport.position, airport.title);
});
По клику по иконке появляется дополнительная информация
По клику по иконке появляется дополнительная информация

От себя добавлю, что мы можем использовать не только SVG формат, но и PNG, JPEG и прочие. Всё, что можно добавить в HTML, можно закрепить за координатой на карте.

Также добавлю, что для отображения маркера и всплывашки вместо HTML мы могли бы использовать и векторный слой. Или добавить свою render-функцию в Style и рисовать картинку прямо на canvas-элементе.

Векторный слой

OpenLayers даёт широкие возможности для работы с векторной графикой

Ранее я привёл пример добавления векторного тайлового слоя (`VectorTileLayer`). Векторные тайлы хранятся в базе данных на бэкенде, карта запрашивает и рисует тайлы. Здесь же мы рассмотрим работу именно с геометрией: как её нарисовать, как стилизовать, как добавить интерактив.

Как это работает?

OpenLayers предоставляет нам несколько классов для рисования примитивных геом. фигур:

Из примитивных геометрических фигур складываются сложные, такие как:

Пример создания геометрии:

// Создаем геометрию точки
const geometry = new Point([6216653.792416765, 2341922.265922518])

Зачастую, с геометрией связаны некие абстрактные данные. Это может быть ID, кадастровый номер, стоимость участка и многое другое. Чтобы связать геометрию на карте и разнородные связанные данные, используется Feature. Грубо говоря, `Feature = Geometry + Properties`.

// Создаем фичу, назначая геометрию (точку, созданную ранее) при инициализации
const feature = new Feature(geometry);
// Можем заменить геометрию "на лету"
feature.setGeometry(geometry);
// Можем сохранить ID, чтобы фичу можно было получить в дальнейшем
feature.setId(12345);
// Можем сохранить абстрактные свойства внутри фичи
feature.setProperties({ ... });

Для хранения фич используется векторное хранилище - VectorSource. Оно необходимо, чтобы абстрагировать источник данных.

// Создаем хранилище векторных данных (фич)
const source = new VectorSource({
  // Можем назначить фичи при инициализации
  features: [feature],
});
// Можем добавить "на лету"
source.addFeature(feature);
// В процессе работы, можем получить желаемую фичи по её ID
source.getFeatureById(12345);

В текущем примере, мы геометрию и фичу создали вручную. Однако, порой удобнее оставить ссылку на API бэкенда, возвращающего фичи, чтобы VectorSource сам их запросил и сохранил в себе.

import KML from "ol/format/KML";

const source = new VectorSource({
  // Ссылка на бэкенд
  url: '/some-api',
  // Можем задать форматтер входящих данных
  // Например, для KML формата
  format: new KML(),
});
// Ссылку можно динамически обновить
source.setUrl('/some-api');

Для того, чтобы отрисовать данные из хранилища данных на карте, нужно использовать векторный слой — VectorLayer. Он нужен, чтобы правильно отрисовать на карте фичи из хранилища.

const layer = new VectorLayer({
 source: source,
 // Можем добавить фон
 background: 'rgba(255, 255, 255, 0.5)',
 // Можем добавить HTML-класс, чтобы застилить слой с помощью CSS
 className: 'my-layer',
 // Можем скрыть/показать
 visible: true,
 // Можем изменить порядок между слоями, подняв/опустив слой относительно других
 zIndex: 5,
 // Меняем уровень прозрачности
 opacity,
 // Можем показывать только в границах заданного зума
 minZoom: 3,
 maxZoom: 15,
 // Или в границах заданного разрешения карты
 minResolution: 10_000,
 maxResolution: 1_000_000,
});

Таким образом, шаг за шагом поднимается уровень абстракции. Цепочка добавления геометрии на карту выглядит так: `Geometry -> Feature -> Source -> Layer`.

Добавим геометрических фигур на карту

Ссылки на Онлайн-пример и Исходный код.

Попробуем нарисовать немного простых геометрических фигур на карте. Для этого нам необходимо использовать векторный слой (VectorLayer) и векторное хранилище (VectorSource).

import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";


const source = new VectorSource();
const layer = new VectorLayer({ source: source });


// Нарисуем точку
const point = new Feature({ geometry: new Point([6216653.792416765, 2341922.265922518]) });
source.addFeature(point);


// Нарисуем линию
const line = new Feature({
 geometry: new LineString([
   [6098023.524518171, 2417747.7979814108],
   [6195862.920723196, 2569398.8620992005],
   [6290033.3395705335, 2406740.865908345],
 ]),
});
source.addFeature(line);


// Нарисуем полигон
const polygon = new Feature({
 geometry: new Polygon([
   [
     [5929250.566064502, 2369439.5961051816],
     [5857094.011363296, 2479508.916835835],
     [5968386.324546512, 2583463.275303675],
     [6096800.532065609, 2503968.765887092],
     [6096800.532065609, 2503968.765887092],
     [5929250.566064502, 2369439.5961051816],
   ],
 ]),
});
source.addFeature(polygon);


// Нарисуем окружность
const circle = new Feature({
 geometry: new Circle([6401325.652753751, 2559003.4262524187], 100000),
});
source.addFeature(circle);


map.addLayer(layer);
Несколько простых геометрических фигур на фоне Аравийского полуострова
Несколько простых геометрических фигур на фоне Аравийского полуострова

Таким образом, чтобы нарисовать геометрию, нам просто нужно добавить Feature с необходимой геометрией в VectorSource. Карта обновится автоматически и они будут нарисованы. Работать с этим довольно удобно: всё что связанно с данными в одном месте, а всё что связано со стилизацией или группировкой данных - в другом. Главное - не забыть добавить слой на карту ?

Для примера я сохранил в коде несколько координат. На практике, зачастую их предоставляет либо сервер, либо пользователь, загружая файл с координатами (будь-то GeoJSON, KML, ShapeFile) или рисуя на самой карте.

Классы геометрии очень напоминают объекты из стандарта GeoJSON. И не спроста: классы принимают координаты в формате GeoJSON и рисуют соответствующие фигуры из стандарта, что довольно удобно на практике, поскольку и бекенд и фронтенд подчинены одному стандарту. Как пример, в кольцах из линий Polygon-а первая и последняя координаты должны быть равны:

new Polygon([
   [
     [5929250.566064502, 2369439.5961051816],
	 // ... координаты ...
     [5929250.566064502, 2369439.5961051816],
   ],
])

Есть одно исключение - это Circle: мы можем нарисовать окружность, хотя в стандарте GeoJSON никакой окружности не описано.

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

Стилизация

Ссылки на Онлайн-пример и Исходный код.

Попробуем добавить других стилей для всех фич внутри слоя.

new VectorLayer({
  // Для стилизации используем класс Style
  style: new Style({
    // Заливка
    fill: new Fill({ color: "rgba(230, 161, 79, 0.4)" }),
    // Обводка
   stroke: new Stroke({ color: "rgba(230, 161, 79, 1)", width: 2 }),
    // Стиль для точки
    image: new CircleStyle({
      radius: 6,
      fill: new Fill({ color: "rgba(230, 161, 79, 0.4)" }),
      stroke: new Stroke({ color: "rgba(230, 161, 79, 1)", width: 2 }),
    }),
 }),
});
Векторная геометрия прекрасно стилизуется
Векторная геометрия прекрасно стилизуется

Свойство `color` может быть любым свойством, которое принимает CanvasRenderingContext2D.fillStyle, т.е. цветом, градиентом или паттерном. Ссылка на более сложный пример здесь.

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

Попробуем добавить стили только для полигона

polygon.setStyle([
 // Стили для полигона
 new Style({
   fill: new Fill({ color: "rgba(193, 211, 63, 0.4)" }),
   stroke: new Stroke({ color: "rgba(193, 211, 63, 1)", width: 2 }),
 }),
 // Стили для вершин
 new Style({
   image: new CircleStyle({
     radius: 6,
     fill: new Fill({ color: "rgba(255, 255, 255, 0.7)" }),
     stroke: new Stroke({ color: "rgba(147, 211, 63, 1)", width: 4 }),
   }),
   // Чтобы стилизовать часть геометрии, достаточно получить желаемые части
   // Это могут быть грани для полигона или линий, центр для окружности, вершины полигона
   geometry: function (feature) {
     // Получаем все вершины
     const coordinates = feature.getGeometry().getCoordinates()[0];
     return new MultiPoint(coordinates);
   },
 }),
]);
Выделенные вершины многоугольника
Выделенные вершины многоугольника

Более подробно о стилизации напишу в отдельной статье, а пока что идем дальше.

Взаимодействие с геометрией

Здесь мы рассмотрим классы из группы `ol/interaction/…`  — классы взаимодействий.

Эти классы дают возможность пользователю непосредственно взаимодействовать с векторными данными: рисовать, изменять, выбирать (кликом или наведением мышкой), использовать Drag’n’Drop и прочее.

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

Рисование

Попробуем добавить пользователю возможность нарисовать геометрию на карте

import { Draw } from 'ol/interaction';

// Draw - класс для рисования
const interaction = new Draw({
 type: "Polygon",
 source: source,
});
map.addInteraction(interaction);


// Колбек на завершение рисовании фичи
interaction.on("drawend", (event) => {
 const geometry = event.feature.getGeometry();
 console.log({
   type: geometry.getType(),
   coordinates: geometry.getCoordinates(),
 });
});
Фигура в процессе рисования
Фигура в процессе рисования

Редактирование геометрии

Попробуем добавить пользователю возможность изменять уже существующую геометрию на карте.

import { Modify } from 'ol/interaction';

// Modify - класс для редактирования
const interaction = new Modify({
 // Мы можем передать VectorSource или список фич
 source: source,
});
map.addInteraction(interaction);


interaction.on('modifyend', (event) => {
 console.log(event.features);
});
Так можно изменять фигуру, меняя существ. вершины и добавляя новые
Так можно изменять фигуру, меняя существ. вершины и добавляя новые

Выделение

Попробуем совместить получение знания по Overlay и добавим его при выделении объекта.

import { Select } from 'ol/interaction';

// Select - позволяет пользователю выделять кликом мыши геометрию на карте
const interaction = new Select({
 // Мы можем передать VectorSource или список фич
 source: source,
 style: new Style({
   fill: new Fill({ color: "rgba(255, 255, 255, 0.5)" }),
   stroke: new Stroke({ color: "#674ea7", width: 4 }),
   image: new CircleStyle({
     radius: 6,
     fill: new Fill({ color: "#674ea7" }),
     stroke: new Stroke({ color: "#fff", width: 4 }),
   }),
 }),
});
map.addInteraction(interaction);


// Попап
const popup = document.getElementById("popup").cloneNode(true);
const popupOverlay = new Overlay({
 element: popup,
 positioning: "bottom-center",
 offset: [0, 0],
});
map.addOverlay(popupOverlay);


// Опционально: добавим оверлей сверху над выделенной геометрией
interaction.on("select", (event) => {
 const feature = event.selected[0];
 if (!feature) {
   popupOverlay.setPosition(undefined);
   return;
 }
 const center = getCenter(feature.getGeometry().getExtent());
 const title = feature.getProperties().name;
 popup.querySelector("#title").textContent = title;
 popupOverlay.setPosition(center);
});
Так можно выделить, например, здание офиса и добавить к нему описание
Так можно выделить, например, здание офиса и добавить к нему описание

Контролы (Controls)

Контрол — видимый пользователю интерактивный элемент, который находится в фиксированном положении на карте (зачастую в углах или на краях).

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

Как добавить контрол на карту?

Добавление контрола, так же как и в случае с другими инструментами карты, может происходить либо в конструкторе, либо по вызову `addControl`.

Важно помнить, что по умолчанию, OpenLayers инициализируется с несколькими контролами, в числе которых Zoom, Rotate, Attribution. И если мы хотим их оставить, то необходимо это явно указать.

import { defaults } from "ol/control";


new Map({
  // Мы можем оставить контролы по умолчанию
  controls: defaults().extend([ new ScaleLine() ]),
  // Или не оставлять
  controls: [new ScaleLine()],
});
// Мы можем добавить/убрать контрол в любой момент
map.addControl(new ScaleLine());

Полный список доступных по умолчанию контролов можно посмотреть в документации к API OpenLayers (в поиске следует ввести `ol/control`)

Перемещение пользователя к определённому месту на карте

Ссылки на Онлайн-пример и Исходный код.

Попробуем использовать встроенный контрол ZoomToExtent, чтобы по клику пользователь перемещался в Москву

import { ZoomToExtent } from "ol/control.js";

new ZoomToExtent({
  extent: [
    4076072.4828566443,
    7450792.337368891,
    4300910.783649025,
    7554077.43179539
  ],
  label: "М",
  tipLabel: "Переместиться в Москву",
})
Добавляется контрол слева
Добавляется контрол слева
По нажатию, карта перемещается в Москву
По нажатию, карта перемещается в Москву

Подробнее об этом контроле можно почитать здесь.

Анимируем контрол из примера выше

Ссылки на Онлайн-пример и Исходный код.

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

Наконец-то нашёл место, где уместно показать расширение через наследование ?

import { ZoomToExtent } from "ol/control.js";
import { easeOut } from "ol/easing";

class ZoomToExtentWithAnimation extends ZoomToExtent {
  /**
   * @protected
   */
  handleZoomToExtent() {
    const view = this.getMap().getView();
    const extent = this.extent || view.getProjection().getExtent();

    // Добавляем анимацию
    view.fit(extent, { duration: 300, easing: easeOut });
  }
}

new ZoomToExtentWithAnimation({
  extent: [4076072.4828566443, 7450792.337368891, 4300910.783649025, 7554077.43179539],
  label: "М",
  tipLabel: "Переместиться в Москву",
});

Получается более приятная анимация перехода к заданному месту

Анимация перехода
Анимация перехода

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

Миникарта

Ссылки на Онлайн-пример и Исходный код.

Для этого используем контрол из библиотеки - OverviewMap.

import TileLayer from "ol/layer/Tile.js";
import OSM from "ol/source/OSM.js";
import { OverviewMap } from "ol/control.js";

const сontrol = new OverviewMap({
 // На эти классы добавим CSS (в примере style.css)
 className: "ol-overviewmap ol-custom-overviewmap",
 layers: [new TileLayer({ source: new OSM() })],
 collapseLabel: "\u00BB",
 label: "\u00AB",
 collapsed: false,
});
Миникарта в правом верхнем углу
Миникарта в правом верхнем углу

Миникарта, как и многие контролы, легко стилизуема. Мы можем заменить контейнер на любой HTML-элемент, вынося таким образом миникарту в любое место страницы. Можно заменить тайловый слой и стили.

Немного поигравшись со стилями легко получаем следующий пример комбинации OSM-слоя на основной карте и StadiaMaps в миникарте со скруглёнными краями.

Ссылки на Онлайн-пример и Исходный код.

Миникарта может настраиваться независимо от основной карты
Миникарта может настраиваться независимо от основной карты

Подробнее об этом контроле можно почитать здесь.

Отобразим текущие координаты под курсором

Ссылки на Онлайн-пример и Исходный код.

import MousePosition from "ol/control/MousePosition.js";

const mousePositionControl = new MousePosition({
  // Количество цифр после запятой
  coordinateFormat: createStringXY(4),
  projection: "EPSG:4326",
  className: "control-coordinates ol-unselectable ol-control",
  target: document.querySelector("#map .ol-overlaycontainer-stopevent"),
});
map.addControl(mousePositionControl);

Подробнее об этом контроле можно почитать здесь.

Геолокация

Ссылки на Онлайн-пример и Исходный код.

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

import Feature from "ol/Feature.js";
import Geolocation from "ol/Geolocation.js";
import Point from "ol/geom/Point.js";
import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style.js";
import { Vector as VectorSource } from "ol/source.js";
import { Vector as VectorLayer } from "ol/layer.js";
import Control from "ol/control/Control";

/**
* Создаем свой класс контрола как дочерний от Control
*/
export class GeolocationControl extends Control {
 // При обновлении геометрии в фичах, автоматически произойдёт перерисовка слоя карты
 accuracyFeature = new Feature();
 positionFeature = new Feature();
 layer = new VectorLayer({ source: new VectorSource({ features: [this.accuracyFeature, this.positionFeature] }) });

 constructor() {
   const element = document.createElement("div");
   super({ element: element });

   // Подготавливаем вёрстку
   // Иконка взята из https://icons8.com/icons/set/location
   element.className = "control-geolocation ol-unselectable ol-control";
   const button = document.createElement("button");
   button.innerHTML = '<img src="https://img.icons8.com/ios/50/marker--v1.png" alt="marker--v1"/>';
   element.appendChild(button);
   button.addEventListener("click", this._handleClick.bind(this));

   // Подготавливаем класс, отслеживающий геолокацию
   // Является оберткой над Geolocation API
   // @see https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API
   const geolocation = new Geolocation({ trackingOptions: { enableHighAccuracy: true } });
   geolocation.on("error", (error) => {
     alert(error.message);
   });
   geolocation.on("change:accuracyGeometry", () => {
     this.accuracyFeature.setGeometry(geolocation.getAccuracyGeometry());
   });
   geolocation.on("change:position", () => {
     const coordinates = geolocation.getPosition();
     const geometry = coordinates ? new Point(coordinates) : null;
     this.positionFeature.setGeometry(geometry);
     if (geometry) {
       this.getMap()?.getView().fit(geometry, { duration: 300, maxZoom: 13 });
     }
   });

   // Добавляем чуть более красивых стилей
   this.positionFeature.setStyle(
     new Style({
       image: new CircleStyle({
         radius: 6,
         fill: new Fill({ color: "#3399CC" }),
         stroke: new Stroke({ color: "#fff", width: 2 }),
       }),
     })
   );
   this.geolocation = geolocation;
   this.button = button;
 }
 /**
  * @overrides
  * Метод вызывает OpenLayers при встраивании контрола в карту
  */
 setMap(map) {
   super.setMap(map);
   if (map) {
     this.geolocation.setProjection(map.getView().getProjection());
     map.addLayer(this.layer);
   }
 }
 /**
  * Обработчик клика по кнопке контрола
  */
 _handleClick() {
   this.geolocation.setTracking(true);
 }
}
Слева под кнопками зума видно контрол геолокации
Слева под кнопками зума видно контрол геолокации
Пример моей геолокации (ну, почти ?)
Пример моей геолокации (ну, почти ?)

Стоит обратить внимание на стиль самой точки на карте — она изображена в виде иконки.

new Style({
 image: new Icon({
   width: 36,
   src: "/public/icons/human.svg",
 }),
});

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

Подробнее о классах Geolocation и Control можно почитать, перейдя по ссылкам.

Заключение

Мы рассмотрели и попробовали на практике библиотеку OpenLayers. В дальнейшем я планирую написать ещё несколько статей на более специфичные темы: работа с GeoTIFF, как работает рендер, внутреннее устройство классов, подробнее про интерактив, и так далее. Если у вас остались вопросы, смело оставляйте их в комментариях.

И в завершение хотелось бы узнать, какую библиотеку для построения карт вы используете и почему выбрали именно её?

А я с вами прощаюсь, до новых встреч!

Комментарии (2)


  1. VanishingPoint
    07.09.2024 16:12

    Работал с Leaflet. Здорово намучился когда пытался разобраться с кластеризацией маркеров. Есть какой-то leaflet MarkerCluster, но он работает плохо, под какие-то старые версии, в общем, так и забил.

    А тут это есть из коробки или еще откуда-нибудь?

    Ну смысл такой, что при zoom-out, маркеры (особенно если их много) должны не отрисовываться все, а группироваться в кластеры, которые показываются в виде окружности. Чем больше в кластере маркеров, тем больше диаметр окружности, а по центру в ней - количество маркеров цифрой. Надеюсь понятно объяснил.


  1. hisbvdis
    07.09.2024 16:12
    +1

    С кластерами наборов маркеров не работали через эту библиотеку