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.project(prevLatlng);
        const pxEnd = map.project(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-плагин

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

    Спасибо за внимание!
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое