MapLibre GL vs & Leaflet (Часть 1 – Создание карты)
В настоящее время на рынке представлено несколько решений для работы с картами, многие из них конкурируют друг с другом, а также существует большой выбор инструментов и библиотек, которые позволяют реализовать различные функции картографирования в веб-приложениях. Эти решения постоянно совершенствуются и обновляются, что создает здоровую конкуренцию между ними и мотивирует разработчиков внедрять новые возможности.
В этой статье мы рассмотрим две самые популярные библиотеки для работы с картами — MapLibre и Leaflet. Обе библиотеки получили широкое распространение и часто используются разработчиками для интеграции карт в веб-проекты различного уровня сложности. Мы разберем основные сценарии использования этих библиотек, а также рассмотрим их сильные и слабые стороны. Это позволит понять, какая из них лучше подходит для конкретных задач и на какой библиотеке остановить свой выбор в каждом отдельном случае.
Обе библиотеки представляют из себя дополнительный слой, который отображается поверх любой карты, такой как Open Street Map (OSM), Google Maps, Яндекс.Карты и многие другие популярные платформы. По сути, библиотека предоставляет инструменты для визуализации, манипулирования и взаимодействия с картографическими данными непосредственно внутри вашего приложения, не ограничиваясь каким-либо одним провайдером карт.
Всю работу с тайлами берут на себя сами библиотеки — они автоматически подгружают необходимые фрагменты карты, обеспечивают их отображение в нужном масштабе и позволяют реализовать различные слои с динамическими эффектами. Таким образом, мы как разработчики можем сконцентрироваться на использовании многофункционального и гибкого API, который способен закрыть множество повседневных потребностей.
Для работы с тайлами, если вы используете не свой сервер, понадобится api_key для доступа к вашему аккаунту. То ес��ь при подключении к сторонним сервисам предоставления карт, вы будете вынуждены получить ключ доступа, чтобы идентифицировать себя и контролировать количество совершаемых запросов. Почти все тайловые слои доступны через аккаунты, и на бесплатных версиях у таких сервисов чаще всего установлено лимитированное количество запросов в сутки или месяц, поэтому важно планировать использование карт с учётом этих ограничений.
Статья будет разделена на 3 части: «Создание карты», «Работа с объектами», «Работа с полигонами».
На момент написания статьи React является самой популярной по рейтингам библиотекой, поэтому все примеры будут построены на базе неё.
«Создание карты»
Leaflet
Предполагается, что функция создания карты будет переиспользуемой, поэтому вынесем ее отдельно в utils.
import L from 'leaflet'; import noData from '../assets/images/Nodata.png'; interface CreateMapProps { config: { lat: number; lng: number; tile?: string; container?: string; }; mapOptions: Record<string, any>; tileOptions?: Record<string, any>; startZoom?: number; } const DEFAULT_TILE = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager_labels_under/{z}/{x}/{y}{r}.png'; const DEFAULT_MAP_ID = 'mapId'; const TILE_ID = 'CartoDB.VoyagerLabelsUnder'; const ATTRIBUTION = 'Map data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'; export const createMap = ({ config, mapOptions = {}, tileOptions = {}, startZoom = 16 }: CreateMapProps) => { const startPoint = { lat: config.lat, lng: config.lng, }; const { tile = DEFAULT_TILE, container = DEFAULT_MAP_ID } = config; const map = L.map(container, { zoomControl: false, worldCopyJump: true, ...mapOptions, }).setView(startPoint, startZoom); L.tileLayer(tile, { attribution: ATTRIBUTION, maxZoom: 18, id: TILE_ID, errorTileUrl: noData, ...tileOptions, }) .on('tileerror', (e) => { console.error('Tile not load:', e); }) .addTo(map); return map; };
Разберем, что тут происходит. Утилитка создания карты принимает в себя config с настройками, есть default переменные, которые можно заменить.
DEFAULT_TILE - слой по умолчанию, на разных серверах доступные разные слои (черные, светлые, со специфическими данными и прочие).
DEFAULT_MAP_ID - карте нужен div в HTML DOM дереве, чтобы знать, где появиться.
TILE_ID - чтобы делать манипуляции с тайловым слоем (удалять, вставлять новый).
ATTRIBUTION - ссылка на владельца слоя, библиотеки.
startPoint - стартовые координаты для первого появления.
startZoom - начальный zoom.
Далее берем импортированный класс карты и создаем инстанс карты: const const map = L.map(container, {.
Создаем тайловый слой, он будет доступен в инстансе самой карты: const originalTile = L.tileLayer(tile, {.
Тайловый слой храним в отдельной переменной для удобства работы с ним.
Для корректного отображения стилей карты в точке входа в наше приложение нужно будет вставить глобальные стили leaflet.
import 'leaflet/dist/leaflet.css';
Далее переходим в файл с компонентом карты, импортируем утилитку, создающую инстанс карты, функции React и функции-классы leaflet.
import L, { Map, TileLayer } from 'leaflet'; import { useEffect, useRef } from 'react'; import { createMap } from '#utils/create-map';
Возьмем React.useRef для хранения инстанса карты и вспомогательных вещей, таких как тайловый слой.
Указываем типы: Map - для инстанса карты, TileLayer - для тайловых слоев.useRef используем, потому что внутри карты работают фабрики для внутренней деятельности, объект инстанса будет мутировать, а нам не нужно, чтобы React делал rerender.
const mapRef = useRef<Map | null>(null); const mapTileRef = useRef<TileLayer | TileLayer[] | null>(null);
Нам понадобиться useEffect без зависимостей, в нем мы создадим карту и удалим, когда произойдет unmount компонента. Map & originalTile, которые вернулись из createMap, помещаем в useRef’ы.
На unmount компонента удаляем карту:
useEffect(() => { const { map, originalTile } = createMap({ container: ELEMENT_ID.monitoringMap, customMap, pageOfMapLayer }) || {}; if (map && originalTile) { mapRef.current = map; mapTileRef.current = originalTile; } return () => { mapRef.current?.remove(); }; }, []);
JSX React компонента будет выглядеть так. Все, что нужно - id div’а, где будет расположена карта.
return ( <S.PageWrapper> {isLoadingList && <S.LoaderWrapper />} <S.ContentWrapper> <S.MapContainer id={ELEMENT_ID.mainMap } /> </S.ContentWrapper> </S.PageWrapper>
MapLibre
Главное отличие: MapLibre по дефолту использует WebGL, Leaflet использует Canvas и HTML. Элементы Leaflet м��жно увидеть в DOM дереве. Leaflet так же может использовать WebGL, но с дополнительной внешней библиотекой. Debugging MapLibre сложнее.
На MapLibre шаги создания похожи.
Создаем reusable utils createMap
import { Map, StyleSpecification, LngLatLike } from 'maplibre-gl'; import { mapTiles } from '../config/map-tiles'; export const ATTRIBUTION = 'Map data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'; export const mapConfigDefault: { center: LngLatLike; zoom: number; minZoom: number; maxZoom: number; hash: boolean; } = { center: [37.6193, 55.7668], zoom: 9, minZoom: 3, maxZoom: 18, hash: true, }; export const styleDefault: StyleSpecification = { version: 8, glyphs: 'https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf', sources: { osm: { type: 'raster', tiles: mapTiles.voyager, tileSize: 256, attribution: ATTRIBUTION, }, }, layers: [ { id: 'osm', type: 'raster', source: 'osm', }, ], }; export interface CreateMapI { container: string; mapConfig?: Record<string, any>; styleConfig?: StyleSpecification | null; attributionControl?: false; } export const createMap = ({ container, mapConfig, styleConfig, attributionControl }: CreateMapI) => { const combineStyle: StyleSpecification = { ...styleDefault, ...styleConfig, }; const combineConfig = { ...mapConfigDefault, ...mapConfig, }; return new Map({ container, center: combineConfig.center, zoom: combineConfig.zoom, minZoom: combineConfig.minZoom, maxZoom: combineConfig.maxZoom, hash: combineConfig.hash, style: combineStyle, ...(attributionControl === false && { attributionControl }), }); }; export const formatNewStyle = (tiles: string[], tileName: string): StyleSpecification => { return { version: 8, glyphs: 'https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf', sources: { osm: { type: 'raster', tiles, tileSize: 256, attribution: ATTRIBUTION, }, }, layers: [ { id: `osm-${tileName}`, type: 'raster', source: 'osm', }, ], }; }
createMap для MapLibre выглядит куда сложнее. Здесь уже нет отдельной переменной со слоями, все слои хранятся внутри самого инстанса. Чтобы манипулировать ими, нужно отдельно держать в хранилище данные по слоям, активному слою и сопоставлять это с тем, что есть в инстансе созданной карты. Потом с этим будут небольшие сложности, но это рассмотрим уже в другой части.
ATTRIBUTION – ссылка на владельцев карты и слоя.
mapConfigDefault – дефолтный конфиг, который доступен, если не хотим кастомизировать настройки.
styleDefault – MapLibre требует стили для отрисовки карты, так же как и дефолтный конфиг его можно кастомизировать.
Объединяем дефолтные настройки const combineStyle: StyleSpecification = {.
Объединяем кастомные настройки (если они есть) const combineConfig = {.
Возвращаем созданный инстанс карты, чтобы поместить его в useRef return new Map({.
Функция нужна, чтобы создавать тайловые слои с подложкой.
export const formatNewStyle = (tiles: string[], tileName: string): StyleSpecification => {
В Leaflet мы хранили их в переменной.
const mapTileRef = useRef<TileLayer | TileLayer[] | null>(null);
Отдельно можно обратить внимание на glyphs. Это шрифты для движка MapLibre, чтобы подписывать объекты. В source указываем источник тайлового слоя OSM (Open street Map). В layers ставим имя нашему слою с подложкой, чтобы можно было к нему обратиться, когда понадобится.
Как описывалось выше, в MapLibre слои придется держать в хранилище Redux или Mobx, т.к. работа с ними более сложная нежели в Leaflet.
Примерно так будет выглядеть класс, где будет производиться манипуляция с тайловыми слоями (удаление-создание нового). Есть объект с разными слоями, есть выбранный текущий тайловый слой.
import { makeAutoObservable } from 'mobx'; import { StyleSpecification } from 'maplibre-gl'; import { formatNewStyle } from '../../utils/create-map'; export class SwitchMapLayersStore { currentMapLayer: StyleSpecification | null = null; listMapLayer: { [key: string]: StyleSpecification } = {}; constructor(initMapLayer: string, list: { [key: string]: string[] }) { this.prepareLayers(list); if (Object.keys(this.listMapLayer).length) { this.currentMapLayer = this.listMapLayer[initMapLayer]; } makeAutoObservable(this, {}, { autoBind: true }); } updateCurrentMapLayer(newMapLayer: StyleSpecification) { this.currentMapLayer = newMapLayer; } prepareLayers(list: { [key: string]: string[] }) { if (Object.keys(list).length) { Object.keys(list).forEach((layerKey) => { this.listMapLayer[layerKey] = formatNewStyle(list[layerKey], layerKey); }); } } reset() { this.currentMapLayer = null; this.listMapLayer = {}; } }
Так же, как и в Leaflet, не забываем добавить глобальные стили в первом файле нашего приложения.
import './index.css'; import './assets/styles/fonts.css'; import 'maplibre-gl/dist/maplibre-gl.css';
Уходим в React Component, содержащий карту.
import { useEffect, useRef } from 'react'; import { observer } from 'mobx-react-lite'; import { Map, LngLatBounds } from 'maplibre-gl';
В компоненте из хранилища нужно будет взять класс со слоями, в котором установлен текущий слой (будет отображаться на старте).
const { mapStore: { mapLayers }, } = useStores();
Создаем useRef в компоненте с картой.
const mapRef = useRef<Map | null>(null);
В useEffect без зависимостей создаем карту и помещаем ее в useRef.
useEffect(() => { mapRef.current = createMap({ container: ELEMENT_ID.monitoringMap, styleConfig: mapLayers.currentMapLayer }); // zoom event for disable zoom buttons if (mapRef.current) { setCurrentZoom(mapRef.current.getZoom()); } mapRef.current?.on('zoomend', (e) => { setCurrentZoom(e.target.getZoom()); }); return () => { mapRef.current?.remove(); }; }, []);


Из-за WebGL работа MapLibre кажется плавнее и быстрее, также zoom имеет микрошаги с десятыми долями, от этого он кажется еще более плавным.
MapLibre vs & Leaflet (Часть 2 – Работа с объектами)
В предыдущей статье мы разобрали, как добавить карты MapLibre и Leaflet в проект (ссылка на предыдущую статью). Здесь мы разберем, как добавить объекты на карту.
Leaflet
По аналогии с функцией createMap функция setMarkers будет переиспользоваться, поэтому вынесем её отдельно в utils.
export interface SetMarkerstI { markers: { lat: number; lng: number, info: {[key: string]: any}}[]; layer: FeatureGroup | null; onMarkerClick: () => void } export const renderTooltip = ({ content }: any) => { return ReactDOMServer.renderToString(<div>{content}</div>); }; export const setMarkers = ({ markers = [], layer, onMarkerClick }: SetMarkerstI) => { layer.clearLayers(); markers.forEach((data) => { const icon = L.divIcon({ html: ReactDOMServer.renderToString(<IconToMap />) }); const handleClick = () => onMarkerClick(data.id); const tooltip = { content: ( <StyledTooltip> <StyledTooltipHeader>{data.name}</StyledTooltipHeader> <StyledTooltipRow>{data.address}</StyledTooltipRow> </StyledTooltip> ) }; let marker = L.marker({ lat: data.lat, lng: data.lng }, options).addTo(layer); marker.bindTooltip(renderTooltip(tooltip), tooltipOpt); marker.on('click', handleClick);
Разберем функцию.
markers - на вход мы получаем массив с объектами. Из обязательной информации там должны быть lat & lng, чтобы знать куда нанести объект. Так же можно поместить любую информацию, которая пойдет в Tooltip объекта, когда на него будет наводиться мышка.
layer – дополнительный слой в инстансе карты, где хранятся объекты.
onMarkerClick – функция, что вызывается по клику на объект. Можно сделать любую логику, но мы пока просто передадим в нее id объекта, по которому кликнули.
Первым делом в функции setMarkers очищаются данные из слоя. На тот случай, если функция вызовется 2 раза, чтобы объекты не устанавливались на уже существующие как дубли.
Дальше проходимся по массиву объектов, дополнительно для каждого формируем иконку, логику клика, компонент tooltip, что будет показываться по hover над объектом.
Формируем нативный Leaflet маркер и добавляем его на слой с объектами.
let marker = L.marker({ lat: data.lat, lng: data.lng }, options).addTo(layer);
Привязываем tooltip к объекту.
marker.bindTooltip(renderTooltip(tooltip), tooltipOpt);
Добавляем обработку клика.
marker.on('click', handleClick);
Переходим в компонент с картой.
Для хранения объектов по аналогии с картой будем использовать отдельную переменную.
const markersRef = useRef<FeatureGroup | null>(null);
Напишем функцию для обработки кликов по объектам, что пойдет в функцию setMarkers.
const onMarkerClick = (objectId: number) => { // Здесь логика обработки клика return; };
Возьмем useEffect и поставим в зависимость массив с объектами. Когда придут объекты, функция нанесет их на карту.
mapRef.current?.addLayer(markersRef.current);
MapLibre
Добавление объектов в MapLibre выглядит немного сложнее. Здесь будет 2 хелпера initMapServices и setObjects для создания объектов. Начнем с первой переиспользуемой функции initMapServices, создающей слой для отображения объектов.
export const initMapServices = ({ mapRef }: { mapRef: { current: Map | null } }) => { mapRef.current?.addSource('markers', { type: 'geojson', data: { type: 'FeatureCollection', features: [], } } mapRef.current?.addLayer({ id: 'markers', type: 'symbol', source: 'markers', filter: ['!', ['has', 'point_count']], layout: { 'icon-image': ['get', 'icon'], 'icon-size': 0.4, }, }); // inspect a marker on click mapRef.current?.on('click', 'markers', async (e) => { const features = mapRef.current?.queryRenderedFeatures(e.point, { layers: ['markers']], }); }); // cursor pointer for uncluster marker and Popup mapRef.current?.on('mouseenter', C.MAP_LAYERS_IDS['unclustered-markers'], (e) => { const { features = [] } = e; const markerData = features[0].properties as I.FormatPopupI; // set unclustered popup unclusteredMarkerPopup = formatUnclusteredPopup(markerData); if (mapRef?.current) { // add unclustered popup to map unclusteredMarkerPopup.addTo(mapRef?.current); popups.push(unclusteredMarkerPopup); } const mapElement = mapRef.current?.getCanvas(); if (mapElement) { mapElement.style.cursor = 'pointer'; } }); // remove cursor pointer after uncluster marker mapRef.current?.on('mouseleave', C.MAP_LAYERS_IDS['unclustered-markers'], () => { if (mapRef?.current) { // remove unclustered popup from map unclusteredMarkerPopup?.remove(); popups = []; } const mapElement = mapRef.current?.getCanvas(); if (mapElement) { mapElement.style.cursor = ''; } }); };
В initMapServices происходят следующие шаги:
Добавляем на карту источник
markers, который как в Leaflet представляет из себя типFeatureCollection;Добавляем в созданный источник
markersслой с именемmarkers, по id функция поймет, в какой источник добавить данные;Добавляем обработку клика по объекту. В функции указываем тип события
clickи слой, к которому он относитсяmarkers;Добавляем обработку
mouseenterиmouseleave, чтобы показывать tooltip над объектами. В функциях указываем, какое событие мы хотим обрабатывать, и к какому слою относится этот обработчик. Внутри нужно будет сформировать html для tooltip’а. В примере для краткости он вынесен в отдельную функциюformatUnclusterPopup.
Слой с логикой есть, теперь нужно написать функцию для добавления объектов на карту через созданный слой. Логика добавления разделена на 4 функции. Это необходимость: так как некоторые части вызываются под разные условия, просто так код не дублируется.
setObjects
Делаем проверку. Если нет объектов, то ничего не делаем, если есть - запускаем логику. Основная проблема MapLibre заключается в том, что у карты есть внутренний loader, и если попытаться что-то делать во время загрузки, то появится ошибка. loader нужно контролировать на разных уровнях, поэтому лучше хранить флаг isLoading во внешнем хранилище. Так мы точно знаем, сможем ли работать с картой в текущий момент или нет.
Дальше смотрим: если карта загружена, то запускаем следующий хелпер, если нет - ставим слушатель, который выполняется один раз на ‘idle’. Это событие вызывается, когда карта совершила какое-либо действие.
setObjectsHelper
objectSource - получаем из инстанса карты нужный источник слоя, он называется markers
getJsonData - подготавливаем данные для объектов
Когда данные готовы, запускаем их установку.
setData - это внутренняя функция GeoJSONSource у MapLibre.
convertObjectsToGeoJSONWithIcons
Формируем объект типа ‘FeatureCollection’ с гео-данными и информацию об объектах.
formatCustomMarker
Стандартная иконка нам не подходит, используем кастомную. Здесь формируется html, который преобразуется в base64, и то, как картинка добавляется на карту.
export interface FormatCustomMarkerI { mapRef: { current: Map | null }; obj: ObjectStore | null; isActive: boolean; } export const formatCustomMarker = ({ mapRef, obj, isActive }: FormatCustomMarkerI) => { // format custom marker icon from html to string to base64 const createMarkerElement = htmlToSvgToBase64({ html: markerIcon({ isActive }), svgSize: { width: 155, height: 140 }, elementSize: { width: 155, height: 140 }, }); // save Marker Element to completed string image in base64 const imageUrl = `data:image/svg+xml;base64,${createMarkerElement}`; // create empty image const image = new Image(); // association empty src img, after added to map with base64 img image.src = imageUrl; // when image created image.onload = () => { if (!mapRef.current?.hasImage(`icon-${obj?.id}`)) { // add empty image to map, if map has not it mapRef.current?.addImage(`icon-${obj?.id}`, image, { pixelRatio: 1 }); } }; // if image error, make warning image.onerror = (error) => { console.log('Ошибка загрузки изображения', error); }; };
export interface ConvertObjectsToGeoJSONReturnI { type: 'FeatureCollection'; features: { type: 'Feature'; properties: { id: string | number; name: string }; geometry: { type: 'Point'; coordinates: [number, number] }; }[]; } const convertObjectsToGeoJSONWithIcons = ({ objectList, mapRef, }: I.ConvertObjectsToGeoJSONI): ConvertObjectsToGeoJSONReturnI => { return { type: 'FeatureCollection', features: objectList.map((obj) => { // format map marker formatCustomMarker({ mapRef, obj, isActive: false }); return { type: 'Feature', properties: { id: obj.id, name: obj.name, lat: obj.latitude, lon: obj.longitude, geotag: obj.geotag, icon: `icon-${obj.id}`, }, geometry: { type: 'Point', coordinates: [obj.longitude, obj.latitude], }, }; }), }; };
export interface SetObjectsHelperI { mapRef: { current: Map | null }; objectList: ObjectStore[]; } const setObjectsHelper = ({ mapRef, objectList }: SetObjectsHelperI) => { const objectSource = mapRef.current?.getSource(C.MAP_LAYERS_IDS.markers) as GeoJSONSource; const geoJsonData = convertObjectsToGeoJSONWithIcons({ objectList, mapRef }); if (objectSource) { return objectSource.setData(geoJsonData); } };
export interface SetObjectsI { objectList: ObjectStore[]; mapRef: { current: Map | null }; } export const setObjects = ({ objectList, mapRef, setIsLoadingDataToMap }: SetObjectsI) => { if (!objectList.length) { return []; } const isMapLoaded = mapRef.current?.loaded(); // set additional loading when drawing objects at the map setIsLoadingDataToMap(true); if (isMapLoaded) { const resultOfSetObjects = setObjectsHelper({ mapRef, objectList }); if (resultOfSetObjects) { // when objects drawn, we got result of drawing and can remove additional loading setIsLoadingDataToMap(false); } } else { // Check map load mapRef.current?.once('idle', () => { const resultOfSetObjects = setObjectsHelper({ mapRef, objectList }); if (resultOfSetObjects) { // when objects drawn, we got result of drawing and can remove additional loading setIsLoadingDataToMap(false); } }); } };


Сложность MapLibre окупается исчерпывающим функционалом, который идет прямо из коробки. К примеру, если вам нужно использовать кластеризацию, то в MapLibre уже все есть, а для Leaflet придется применять сторонние пакеты. Но на Leaflet кластеризация из стороннего пакета будет работать немного лучше за счет отсутствия десятых долей в шагах на zoom, в MapLibre же могут возникать артефакты. Также при выходе объекта из кластера он может не появиться на карте, при этом из кластера он уже будет исключен. Плюс счетчик объектов в кластере. Это отдельная сущность на карте, при появлении и уходе кластера он может появиться раньше самого кластера.
Еще один момент: если вы используете TypeScript, то скорее всего придется писать кастомные типы для объектов, которые возвращают функции MapLibre и Leaflet. Если провалиться внутрь пакетов, то вы увидите там union типы разных сущностей. И так как это union, он возвращает только общие поля, которых может и не быть.
И главный момент: если провалиться внутрь пакета MapLibre, то можно увидеть api от Leaflet. Оно, конечно, не будет работать, так как логика пакетов разная.
MapLibre vs & Leaflet (Часть 3 – Работа с полигонами (geozones))
В предыдущей статье мы разобрали, как добавить объекты в библиотеках MapLibre и Leaflet в проект (ссылка на предыдущую статью). Здесь мы разберем, как добавить геозоны на карту.
Leaflet
Нужно будет создать 2 дополнительные React.useRef для хранения слоя с зонами. zonesRef пойдет для хранения всех нанесенных зон на карту, selectedZoneRef - для хранения выделенной зоны, если вдруг нужно будет проводить какие-то манипуляции с выделенной зоной. Тип у зон FeatureGroup, тип у добавляемой зоны - GeoJson, который представляет из себя расширенный тип FeatureGroup
const zonesRef = useRef<FeatureGroup | null>(null); const selectedZoneRef = useRef< FeatureGroup | null>(null);
В useEffect без зависимостей после создания карты добавляем слои в инстанс карты.
if (mapRef.current) { zonesRef.current = L.featureGroup().addTo(mapRef.current); selectedZoneRef.current = L.featureGroup().addTo(mapRef.current); }
Дальше создаем еще один useEffect, где в зависимостях будет массив с зонами для карты. Сначала проверяем наличие карты и слоя для полигонов, если их нет, то не делаем ничего во избежание ошибки. Очищаем слой, чтобы не допустить второго наложения, если вдруг в слоях уже что-то было.
Проходим по массиву с зонами, деструктуризируем информацию, создаем tooltip для каждой зоны с информацией по hover. Из пришедших геоданных создаем нативную Leaflet зону, привязываем к ней tooltip, обработчик на click и добавляем в слой с полигонами.
useEffect(() => { const hasRefs = mapRef.current && zonesRef.current; if (!hasRefs) { return; } zonesRef.current.clearLayers(); geozonesMap.forEach((geozone) => { const { id, geoJson, props } = geozone; const tooltip = new L.Tooltip({ direction: 'top', permanent: false, sticky: true }, geoJson); tooltip.setContent(geozone.name); const zone = L.geoJSON(geoJson, getStylesOption(props)); zone.bindTooltip(tooltip); zone.on('click', () => openGeofence(id)); zone.addTo(zonesRef.current); }); }, [geozonesMap]);
Для удобства стили для зоны вынесены в отдельную функцию.
export const getStylesOption = (props: { [name: string]: string } | null) => { if (!props) { return; } const options: L.GeoJSONOptions = { style: { color: props?.borderColor ?? colors.primary, fillColor: props?.borderColor ?? colors.primary, fillOpacity: 0.2, dashArray: getContourSetting(props?.borderStyle as Contour), }, }; return options; };
MapLibre
В MapLibre, как и в работе с объектами, не будет отдельной переменной для хранения полигонов. Для этого мы создадим отдельный источник source и добавим в него layer с полигонами.
Ранее мы создавали helper, который вызывается при инициализации карты и добавляет нужные источники и слои. Разделим логику добавления зон на 2 части: источники и слои для объектов и зон будут добавляться централизованно в одном месте при инициализации карты, а вот само добавление будет происходить в отдельных функциях. Логика с объектами оставлена для информативности.
export const initMapServices = ({ mapRef }: { mapRef: { current: Map | null } }) => { mapRef.current?.addSource('markers', { type: 'geojson', data: { type: 'FeatureCollection', features: [], } // Добавляем источник mapRef.current?.addSource(‘geozones’, { type: 'geozones', data: { type: 'FeatureCollection', features: [], } } // Добавляем слой mapRef.current?.addLayer({ 'id': 'geozones', 'type': 'line', 'source': 'geozones', 'paint': { 'line-color': '#888', 'line-width': 8 } }); mapRef.current?.addLayer({ id: 'markers', type: 'symbol', source: 'markers', filter: ['!', ['has', 'point_count']], layout: { 'icon-image': ['get', 'icon'], 'icon-size': 0.4, }, });
Источник и слой с зонами теперь существуют. Нужно добавить useEffect с зонами в зависимостях, где будут вызываться helper'ы для работы с зонами. Для начала в useEffect очистим все предыдущие зоны, чтобы избежать двойного наложения через функцию removeAllZones. Дальше вызовем helper для установки зон setZones. В него передадим массив с зонами, инстанс карты и глобальный флаг, что указывает, что карта загружена.
export const removeAllZones = ({ mapRef }: I.RemoveAllZonesI) => { const zonesSource = mapRef.current?.getSource('geozones') as GeoJSONSource; if (zonesSource) { zonesSource.setData({ type: 'FeatureCollection', features: [], }); } };
// set zones to map useEffect(() => { // erase all zones U.removeAllZones({ mapRef }); // set zones U.setZones({ zoneList, mapRef, setIsLoadingDataToMap }); }, [zoneList]);
Логика полностью дублируется, как при добавлении объектов.
export const setZones = ({ zoneList, mapRef, setIsLoadingDataToMap }: I.SetZonesI) => { if (!zoneList.length) { return []; } const isMapLoaded = mapRef.current?.loaded(); // set additional loading when zones at the map setIsLoadingDataToMap(true); if (isMapLoaded) { const resultOfSetZones = setZonesHelper({ mapRef, zoneList }); if (resultOfSetZones) { // when zones drawn, we got result of drawing and can remove additional loading setIsLoadingDataToMap(false); } } else { // Check map load mapRef.current?.once('idle', () => { const resultOfSetZones = setZonesHelper ({ mapRef, zoneList }); if (resultOfSetZones) { // when zones drawn, we got result of drawing and can remove additional loading setIsLoadingDataToMap(false); } }); } };
const setZonesHelper = ({ mapRef, zoneList }: I.SetZonesHelperI) => { const zonesSource = mapRef.current?.getSource(geozones') as GeoJSONSource; const geoJsonData = formatZones({ objectList, mapRef }); if (objectSource) { return objectSource.setData(geoJsonData); } };
const formatZones = ({ zoneList, mapRef, }: I.FormatZones): I.FormatZonesReturnI => { return { type: 'FeatureCollection', features: zoneList.map((zone) => { return { type: 'Feature', geometry: { type: Polygon, coordinates: [zone.geoData], }, properties: {} }; }), }; };
Разберем поведение из 3х функций: setZones -> setZonesHelper -> formatZones
setZones
Делаем проверку. Если нет зон, ничего не делаем, если есть - запускаем логику. Опять у нас 2 сценария, где запускается похожая логика (когда карта загружена и когда еще нет), поэтому логика вынесена в отдельные функции.
setZonesHelper
Находит нужный источник с зонами, форматирует зоны и вызывает нативную функцию источника setData для установки данных на карту.
formatZones
Форматируем зоны. Пробегаемся по массиву и из каждой зоны берем геоданные. В properties можно указать дополнительные стили и информацию для зоны.


Если резюмировать по библиотекам карт, повторюсь: MapLibre намного сложнее для понимания, нежели Leaflet, но эта сложность полностью компенсируется обширным api, где вам не нужно устанавливать сторонние библиотеки, чтобы рисовать, использовать WebGL или кластеризацию. Все уже встроено. Обе библиотеки обладают и минусами, и плюсами, которые вам предстоит решить, чтобы наслаждаться плавной работой карты. Скажем, если у вас будет 70 000 объектов на карте, кластеризация, кастомные иконки, графики и еще полигоны, то обе библиотеки начнут тормозить, и вам придется писать оптимизацию для вывода ваших данных.
А какие библиотеки для карт используете вы?
