MapLibre GL JS не предоставляет встроенного универсального механизма drag-and-drop для всех объектов карты. Вместо этого интерактивность строится поверх событий указателя, преобразования координат и обновления источников данных. Основная идея заключается в том, что любой «перетаскиваемый объект» — это либо маркер DOM, либо геометрия в GeoJSON-источнике, либо визуальный слой, обновляемый в реальном времени.
Ключевой принцип:
перемещение объекта = постоянное обновление его координат + перерисовка источника или DOM-элемента
Работа drag-and-drop невозможна без понимания преобразования экранных координат в географические и обратно.
MapLibre GL JS использует две основные операции:
map.unproject([x, y]) — перевод пикселей в координаты
[lng, lat]map.project([lng, lat]) — перевод координат в
пикселиПри перетаскивании курсор даёт экранные координаты, но обновлять геометрию нужно в географических:
const lngLat = map.unproject([event.point.x, event.point.y]);
Именно это преобразование лежит в основе всей логики drag-and-drop.
Самый простой вариант — использование HTML-маркеров
(new maplibregl.Marker({ element })).
Маркер представляет собой DOM-элемент, который можно перемещать через API:
const marker = new maplibregl.Marker({ draggable: false })
.setLngLat([30, 50])
.addTo(map);
Далее реализуется ручной drag:
let isDragging = false;
marker.getElement().addEventListener('mousedown', (e) => {
isDragging = true;
e.preventDefault();
});
map.on('mousemove', (e) => {
if (!isDragging) return;
marker.setLngLat(e.lngLat);
});
map.on('mouseup', () => {
isDragging = false;
});
Особенность подхода:
Более производительный и гибкий подход — работа через GeoJSON source.
map.addSource('points', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { id: 1 },
geometry: {
type: 'Point',
coordinates: [30, 50]
}
}
]
}
});
Слой для отображения:
map.addLayer({
id: 'points-layer',
type: 'circle',
source: 'points',
paint: {
'circle-radius': 8,
'circle-color': '#2E86AB'
}
});
Перед началом drag необходимо определить, какой объект захвачен.
Используется queryRenderedFeatures:
map.on('mousedown', 'points-layer', (e) => {
const feature = e.features[0];
const coordinates = feature.geometry.coordinates;
map.draggedFeature = feature;
map.dragStart = e.lngLat;
});
Важно:
e.features доступен только при указании слоя в
обработчикеПри движении мыши пересчитываются координаты.
map.on('mousemove', (e) => {
if (!map.draggedFeature) return;
const newCoords = [e.lngLat.lng, e.lngLat.lat];
const source = map.getSource('points');
const data = source._data;
for (const feature of data.features) {
if (feature.properties.id === map.draggedFeature.properties.id) {
feature.geometry.coordinates = newCoords;
}
}
source.setData(data);
});
Ключевой момент:
setData полностью перерисовывает источник, поэтому при частых обновлениях требуется оптимизация.
Постоянное обновление setData может быть дорогим.
Альтернативные подходы:
feature-stateЕсли данные статичны, можно обновлять только состояние:
map.setFeatureState(
{
source: 'points',
id: 1
},
{
lng: e.lngLat.lng,
lat: e.lngLat.lat
}
);
Далее стиль слоя может учитывать состояние.
Каждый draggable объект — отдельный feature source:
Обновление массива только при mouseup, а не при каждом
mousemove.
Более стабильный способ — вычисление смещения относительно точки захвата.
map.on('mousedown', (e) => {
const feature = map.queryRenderedFeatures(e.point, {
layers: ['points-layer']
})[0];
if (!feature) return;
map.dragState = {
id: feature.properties.id,
startLngLat: feature.geometry.coordinates,
startPoint: e.point
};
});
Далее:
map.on('mousemove', (e) => {
if (!map.dragState) return;
const start = map.dragState.startPoint;
const current = e.point;
const deltaLngLatStart = map.unproject(start);
const deltaLngLatCurrent = map.unproject(current);
const lng = map.dragState.startLngLat[0] +
(deltaLngLatCurrent.lng - deltaLngLatStart.lng);
const lat = map.dragState.startLngLat[1] +
(deltaLngLatCurrent.lat - deltaLngLatStart.lat);
updateFeature(map.dragState.id, [lng, lat]);
});
Этот метод обеспечивает:
Перетаскивание точек усложняется, когда речь идёт о LineString или Polygon.
Пример структуры:
geometry: {
type: 'Polygon',
coordinates: [
[
[30, 50],
[31, 50],
[31, 51],
[30, 50]
]
]
}
При drag изменяется конкретная вершина:
coords[ringIndex][vertexIndex] = [lng, lat];
Для сложных объектов используется ручной поиск ближайшей точки:
function findNearestVertex(polygon, lngLat) {
let minDist = Infinity;
let result = null;
polygon.coordinates[0].forEach((coord, i) => {
const dx = coord[0] - lngLat.lng;
const dy = coord[1] - lngLat.lat;
const dist = dx * dx + dy * dy;
if (dist < minDist) {
minDist = dist;
result = i;
}
});
return result;
}
Часто создаётся дополнительный слой:
map.addLayer({
id: 'vertices',
type: 'circle',
source: 'polygon-source',
filter: ['==', ['get', 'type'], 'vertex']
});
Это позволяет:
Иногда требуется ограничить перемещение:
Пример ограничения:
function clampLngLat(lngLat) {
return {
lng: Math.max(29, Math.min(31, lngLat.lng)),
lat: Math.max(49, Math.min(52, lngLat.lat))
};
}
Для привязки к сетке используется округление:
function snap(value, step) {
return Math.round(value / step) * step;
}
Применение:
const lng = snap(lngLat.lng, 0.01);
const lat = snap(lngLat.lat, 0.01);
Это часто используется в редакторах картографических объектов.
Для более сложных UX можно добавить инерцию:
let velocity = { lng: 0, lat: 0 };
map.on('mousemove', (e) => {
velocity = {
lng: e.lngLat.lng - prev.lng,
lat: e.lngLat.lat - prev.lat
};
prev = e.lngLat;
});
После mouseup:
function animate() {
if (!dragging) {
lng += velocity.lng * 0.9;
lat += velocity.lat * 0.9;
updateFeaturePosition([lng, lat]);
requestAnimationFrame(animate);
}
}
Одна из сложных проблем — изменение масштаба карты во время перетаскивания.
Решение:
anchor pointunprojectmap.on('zoom', () => {
if (!dragging) return;
const newLngLat = map.unproject(anchorPixel);
});
Если drag влияет на группу объектов:
const updated = features.map(f => {
if (f.properties.group === activeGroup) {
f.geometry.coordinates = shift(f.geometry.coordinates);
}
return f;
});
При десятках тысяч объектов:
setData на каждый mousemovelet lastUpdate = 0;
map.on('mousemove', (e) => {
const now = performance.now();
if (now - lastUpdate < 16) return;
lastUpdate = now;
updateGeometry(e.lngLat);
});
Типичная структура:
Такое разделение позволяет:
Любой drag можно представить как конечный автомат:
const state = {
mode: 'idle',
activeFeature: null
};
Это предотвращает конфликт событий mousemove,
mouseup, mouseleave.
Для мобильных устройств используются:
touchstarttouchmovetouchendMapLibre уже нормализует часть событий, но для кастомного drag требуется явная обработка.
На базе описанных механизмов строятся:
Их основа всегда одна:
управление геометрией через pointer events + обновление GeoJSON + преобразование координат