Искусство сомнения
Тексты и Код Алексея Пожарова

Зачем нужен VueMapbox

Хорошие новости

Библиотека Mapbox Gl JS прекрасна. Если вам нужно создать веб-приложение для работы с географическими картами, реализовать в нём сложный функционал, то трудно найти инструмент лучше. Она красивая. Она быстрая. Она открытая. Она уникальна в своём роде: по-моему, это единственная живая и развиваемая открытая реализация векторных карт в браузере. Был ещё MapZen, но его разработка прекращена, так что выбор на самом деле не так уж велик.

Vue тоже хорош. Это фреймворк для построения интерфейса в котором найден хороший баланс между простой и функциональностью, "магией" и возможностями для тонкой настройки. Его система реактивности хорошо помогает в разработке сложных интерфейсов и почти никогда не путается у вас под ногами. Почти… но это требует отдельного поста 😉.

Новости похуже

Сами по себе обе библиотеки прекрасны, а вот уживаются они не очень. Друг другу они, конечно, не мешают, но если логика вашего интерфейса построена в парадигме Vue и вы хотите и дальше пользоваться его преимуществами, то возникает вопрос: как в эту парадигму вписывается Mapbox Gl JS? Ответ не очень утешительный: примерно никак.

Тут необходимы пара небольших отступлений для прояснения вопроса.

Декларативный стиль Vue

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

<template>
    <MyMap
        :style="mapStyle"
        :center="mapCenterCoordinates"
        …some other props…
    />
</template>

Обычно на карте размещаются какие-нибудь объекты, например, маркеры, чтобы отметить какие-либо места. Опять же, хотелось бы видеть их в виде компонентов, которые естественно разместить внутри карты, так же, как мы бы поступили с какими-то HTML-элементами. Если у нас есть список мест, то мы бы создали список с данными маркеров и добавили их с помощью встроенной директивы v-for. Кроме того, содержимое маркера хотелось бы менять в зависимости от условий, а также добавлять и убирать их с помощью v-if. Что-то типа:

<template>
    <MyMap
        :style="mapStyle"
        :center="mapCenterCoordinates"
        …some other props…
    >
        <MapMarker
            v-for="marker in markers"
            v-if="markerCondition"
            :coordinates="marker.coordinates"
        >
            <span>I am marker!</span>
        </MapMarker>
    </MyMap>
</template>

Если данные меняются, то компоненты должны реагировать на это соответственно. Vue предоставляет элегантное решение для двойного связывания (two-way data binding) с помощью synced props. Если какой-то метод меняет состояние карты (например, мы добавили красивый перелёт карты к какому-то месту), было бы здорово использовать двойное связывание для обновления данных.

Также, карта и её элементы могут генерировать события, и естественно слушать их на компонетах самой карты и её элементов.

Как-то так (обратите внимание на .sync у параметра center):

<template>
    <MyMap
        :style="mapStyle"
        :center.sync="mapCenterCoordinates"
        @movend="moveHandler"
        …some other props…
    >
        <MapMarker
            v-for="marker in markers"
            v-if="markerCondition"
            @click="markerClickHandler(marker)"
            :coordinates="marker.coordinates"
        >
            <span>I am marker!</span>
        </MapMarker>
    </MyMap>
</template>

Всё как во Vue.

Императивный стиль Mapbox GL JS

Проблема в том, что Mapbox GL JS это совершенно другой зверь и спроектиован для работы в другой парадигме. Для того, чтобы создать карту, нам надо вызвать её конструктор и передать ему все необходимые параметры, включая id HTML-элемента, в котором будет рисоваться карта. Конструктор вернёт нам объект Map с которым мы и должны дальше работать.

Пример из документации Mapbox GL JS:

import mapboxgl from "mapbox-gl";

const map = new mapboxgl.Map({
    container: "map",
    style: "mapbox://styles/mapbox/streets-v9"
});

Если мы хотим, к примеру, поменять центр карты, нам надо вызвать соответствующий метод:

import mapboxgl from "mapbox-gl";

const map = new mapboxgl.Map({ /* …map config… */});

map.setCenter([50, 50]);

И так далее. Любые манипуляции с картой делаются через методы этого объекта. Тоже самое с получением данных. Чтобы узнать координаты центра карты, мы должны вызвать соответсвующий метод:

import mapboxgl from "mapbox-gl";

const map = new mapboxgl.Map({ /* …map config… */});
const center = map.getCenter();

Если мы хотим слушать события карты, то нужно добавить обработчики событий к самому объекту Map:

const map = new mapboxgl.Map({ /* …map config… */});

const moveHandler = event => { /* …some actions… */ };

map.on("moveend", moveHandler);

И если элементы карты, например, слои, генерируют какие-то события, то нам нужно опять же слушать их на самом объекте карты, передав идентификатор слоя в качестве «фильтра»:

const map = new mapboxgl.Map({});

const layerId = "myLayer";

const clickHandler = event => { /* …some actions… */ };

map.on("click", myLayer, clickHandler);

Не очень-то похоже на наш гипотетический пример выше в декларативном стиле… Ах да, слои. О слоях стоит сказать несколько слов отдельно.

Слои в Mapbox GL JS

Сами слои не представлены в виде объектов в API Mapbox GL. Чтобы добавить слой на карту нужно вызвать метод карты .addLayer(), указав источник данных. Пример из документации Mapbox GL JS:

map.on("load", function () {
    map.addLayer({
        "id": "main",
        "type": "fill",
        "source": {
            "type": "geojson",
            "data": {/* …GeoJSON data */}
        },
        "layout": {},
        "paint": {
            "fill-color": "#088",
            "fill-opacity": 0.8
        }
    });
});

Все дальнейшие манипуляции производятся также с помощью методов карты с передачей id слоя в качестве аргумента. Всё это значит, что работать со слоями в объектно-ориентированном и декларативном стиле очень неудобно. В частности, для добавления или удаления слоёв было бы удобно использовать хуки жизненного цикла Vue, однако напрямую это невозможно. По сути, для этого нам нужно реализовать отдельную прослойку, которая будет предоставлять декларативный API и заботится о трансляции всех необходимых изменений на карту.

Асинхронные методы и события

В одном из проектов, с которыми я работал, требовалось последовательно показывать ряд анимаций на карте, останавливая/изменяя их в зависимости от действий пользователя, и я столкнулся с тем, что определить момент, когда асинхронные методы карты, такие как .flyTo(), закончили работу, не так-то просто. В JavaScript для этих целей существет Promise. Но, к сожалению, асинхронные методы не возвращают Promise и, судя по этому фич-реквесту, серьёзных подвижек в этом направлении в ближайшее время не предвидится. Mapbox GL JS генерирует события, когда какое-либо действие заканчивается, например, события moveend, rotateend etc. Можно подписаться на эти события, но если на карте происходит несколько действий одновременно или порядок их может меняться, то как нам определить, какое имеено действие вызвало то или иное событие? Механизм для этого есть, но его трудно назвать удобным в использовании.

В асинхронные методы карты можно передать объект eventData в качестве аргумента. Когда какое-либо дествие с картой заканчивается, Mapbox GL JS генерирует событие и возвращает в качестве нагрузки этот объект. Таким образом, мы можем сгененрировать уникальный ID для каждого действия и по нему определить, какой именно метод вызвал определённое событие:

const actionId = "uniqueActionId";
map.on("moveend", event => {
    if (actionId === "uniqueActionId") {
        // Do something!
    }
});
map.flyTo({ center: [50, 50] }, { actionId: "uniqueActionId" });

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

const callback = () => { console.log("Done!") };
map.on("moveend", event => {
    if (event.callback) {
        event.callback()
    }
});
map.flyTo({ center: [50, 50] }, { callback });

Делать так каждый раз довольно утомительно и не очень красиво, так что я написал небольшую библиотеку map-promisified, которая оборачивает методы Mapbox GL JS в асинхронные функции, кторые возвращают Promise. Когда действие заканчивается, Promise разрешается, возвращая данные об изменившихся параметрах карты.

Счастливый конец: VueMapbox

В результате упорной борьбы с несовпадением парадигм программирования двух замечательных библиотек, родилась новая: Vue-Mapbox. Она решает описанные выше проблемы, предоставляя удобный декларативный API для работы с картой. Распространияется под лицензией MIT и весит около 7Кб в сжатом виде.

Vue Mabpox Документация Исходный код Vue and JavaScript