Leaflet. Дружим Image с Canvas
Доброго времени суток, дорогие хабрахабровцы!
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-плагин
Этот плагин легко подключить и использовать в своих проектах! Так же плагин поддерживает дополнительные настройки, не описанные в данной статье.
Спасибо за внимание!