Leaflet. Дружим Image с Canvas

Leaflet Map

Доброго времени суток, дорогие хабрахабровцы!

Leaflet — библиотека, позволяющая добавить интерактивные карты на Ваш сайт и легко их кастомизировать. Сегодня рассмотрим то, как можно разместить изображения на Canvas-слое карт, совместно с базовыми маркерами.

Задача


Построить трек с отметкой различных статусов состояния. Статусы отмечаются маркерами. У каждого статуса есть свой приоритет.

  • Для оптимизации карты, рендеринг объектов должен происходить с использованием Canvas.
  • Маркеры могут быть двух типов: точки и изображения.
  • Если маркеры перекрывают друг друга — то сверху должен оказаться маркер более приоритетного статуса.
  • Каждый маркер должен быть активным при наведении на него мышкой (например для вывода дополнительной информации).


Подготовка


Подключим библиотеку Leaflet.js и добавим базовую карту.

const map = L.map('map', {
    preferCanvas: true,
}).setView([51.505, -0.09], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);


Для наглядности будем использовать 3 состояния в порядке увеличение приоритета: базовый (зеленый маркер), сообщение (изображение) и ошибка (красный маркер).

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

/* Базовый маркер */
L.circleMarker(L.latLng(51.52, -0.109), {
    radius: 10,
    fillColor: '#27ae60',
    fillOpacity: 1,
    color: '#fff',
    weight: 3,
}).addTo(map);

/* Маркер сообщения */
L.marker(L.latLng(51.52, -0.109), {
    icon: L.icon({
        iconUrl: 'icon.png',   // url картинки
        iconSize: [40, 40],   // размер маркера
        iconAnchor: [20, 20],   // выравнивание относительно центра
    }),
}).addTo(map);

/* Маркер ошибки */
L.circleMarker(L.latLng(51.52, -0.109), {
    radius: 8,
    fillColor: '#f44334',
    fillOpacity: 1,
    color: '#fff',
    weight: 3,
}).addTo(map);


Проблема


Leaflet добавляет маркеры поочередно, поэтому каждый последующий должен перекрывать предыдущий. Но на деле это не так. L.marker добавляет изображение в качестве обыкновенного IMG, отдельно от слоя Canvas.

Его можно разместить либо перед, либо под Canvas. И как следствие, невозможно поместить L.marker между двух L.circleMarker.

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

Примечание: В сети есть несколько плагинов, позволяющих добавлять изображения на Canvas. Но они создают отдельный Canvas, или даже группу слоев! В итоге простое размещение маркеров по приоритету становится довольно затруднительным. А так же Canvas-слои перекрывают друг друга, и кликнуть мышкой на маркер нижестоящего слоя становится невозможным!

Решение


Шаг 1. Создаем дочерний класс от L.CircleMarker, который будет получать объект 'img', загружать изображение и добавлять его в L.Canvas.

const CanvasMarker = L.CircleMarker.extend({
    _updatePath() {
        if (!this.options.img.el) { //Создаем элемент IMG
            const img = document.createElement('img');
            img.src = this.options.img.url;
            this.options.img.el = img;
            img.onload = () => {
                this.redraw();  //После загрузки запускаем перерисовку
            };
        } else {
            this._renderer._updateImg(this);    //Вызываем _updateImg
        }
    },
});

L.canvasMarker = function (...options) {
    return new CanvasMarker(...options);
};


Шаг 2. Описываем метод _updateImg в L.Canvas. Он получает объект с изображением, который мы передаем на Шаге 1 и рисует его на Canvas.

L.Canvas.include({
    _updateImg(layer) { //Метод добавления img на Canvas-слой
        const { img } = layer.options;
        const p = layer._point.round();
        this._ctx.drawImage(img.el, p.x - img.size[0] / 2, p.y - img.size[1] / 2, img.size[0], img.size[1]);
    },
});


Шаг 3. Теперь вместо L.marker можно использовать L.canvasMarker. Обратите внимание, что параметр 'anchor' не используется, т.к. картинка выравнивается автоматически!

/* Базовый маркер */
    L.circleMarker(L.latLng(51.52, -0.109), {
        radius: 10,
        fillColor: '#27ae60',
        fillOpacity: 1,
        color: '#fff',
        weight: 3,
    }).addTo(map);

    /* Маркер сообщения */
    L.canvasMarker(L.latLng(51.52, -0.109), {
        img: {
            url: 'icon.png',
            size: [40, 40],
        },
    }).addTo(map);

    /* Маркер ошибки */
    L.circleMarker(L.latLng(51.52, -0.109), {
        radius: 8,
        fillColor: '#f44334',
        fillOpacity: 1,
        color: '#fff',
        weight: 3,
    }).addTo(map);


В результате:

  • Все маркеры расположены на едином Canvas-слое.
  • Маркеры перекрывают друг-друга в порядке их добавления на карту.
  • При наведении на маркеры мышкой, они сохраняют активность.


Задача решена!

Дополнительно


Давайте «прокачаем» наш метод L.canvasMarker и добавим возможность автоматически разворачивать изображение в направлении движения по карте!

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

L.Canvas.include({
    _updateImg(layer) {
        const { img } = layer.options;
        const p = layer._point.round();
        if (img.rotate) {
            this._ctx.save();
            this._ctx.translate(p.x, p.y);
            this._ctx.rotate(img.rotate * Math.PI / 180);
            this._ctx.drawImage(img.el, -img.size[0] / 2, -img.size[1] / 2, img.size[0], img.size[1]);
            this._ctx.restore();
        } else {
            this._ctx.drawImage(img.el, p.x - img.size[0] / 2, p.y - img.size[1] / 2, img.size[0], img.size[1]);
        }
    },
});


Как видно из примера, для поворота у 'img' должно быть свойство 'rotate'. И мы уже можем задать его вручную при добавлении маркера:

L.canvasMarker(L.latLng(51.52, -0.109), {
    img: {
        url: 'icon.png',
        size: [40, 40],
        rotate: 15, //угол поворота изображения
    },
}).addTo(map);


Но нам нужно вычислять угол поворота автоматически на основе предыдущей точки. Поэтому добавим вычисление угла на основе двух координат (angleCrds):


const angleCrds = (map, prevLatlng, latlng) => {
    if (!latlng || !prevLatlng) return 0;
    const pxStart = map.latLngToLayerPoint(prevLatlng);
    const pxEnd = map.latLngToLayerPoint(latlng);
    return Math.atan2(pxStart.y - pxEnd.y, pxStart.x - pxEnd.x) / Math.PI * 180 - 90;
};

const CanvasMarker = L.CircleMarker.extend({
    _updatePath() {
        if (!this.options.img.el) {
            /* Вызываем метод */
            if (!this.options.img.rotate) this.options.img.rotate = 0;
            this.options.img.rotate += angleCrds(this._map, this.options.prevLatlng, this._latlng);

            const img = document.createElement('img');
            img.src = this.options.img.url;
            this.options.img.el = img;
            img.onload = () => {
                this.redraw();
            };
        } else {
            this._renderer._updateImg(this);
        }
    },
});

L.canvasMarker(L.latLng(51.52, -0.109), {
    prevLatlng: L.latLng(51.528, -0.1), // Координаты предыдущей точки
    img: {
        url: 'icon.png',
        size: [40, 40],
    },
}).addTo(map);



Заключение


→ Пример работы можно увидеть здесь
→ Весь описанный функционал я вынес в отдельный npm-плагин

Этот плагин легко подключить и использовать в своих проектах! Так же плагин поддерживает дополнительные настройки, не описанные в данной статье.

Спасибо за внимание!

© Habrahabr.ru