Drag and drop объектов

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.


Перетаскивание через DOM-маркеры

Самый простой вариант — использование 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;
});

Особенность подхода:

  • высокая простота реализации
  • низкая нагрузка
  • ограниченная масштабируемость при большом количестве объектов

Drag GeoJSON-объектов

Более производительный и гибкий подход — работа через 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 доступен только при указании слоя в обработчике

Реализация перемещения GeoJSON точки

При движении мыши пересчитываются координаты.

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 полностью перерисовывает источник, поэтому при частых обновлениях требуется оптимизация.


Оптимизация drag для GeoJSON

Постоянное обновление setData может быть дорогим. Альтернативные подходы:

1. Использование feature-state

Если данные статичны, можно обновлять только состояние:

map.setFeatureState(
  {
    source: 'points',
    id: 1
  },
  {
    lng: e.lngLat.lng,
    lat: e.lngLat.lat
  }
);

Далее стиль слоя может учитывать состояние.

2. Разделение источников

Каждый draggable объект — отдельный feature source:

  • меньше данных на перерисовку
  • проще обновление

3. Кэширование координат

Обновление массива только при mouseup, а не при каждом mousemove.


Продвинутая модель drag через пиксельное смещение

Более стабильный способ — вычисление смещения относительно точки захвата.

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]);
});

Этот метод обеспечивает:

  • стабильность при разных зумах
  • отсутствие «скачков»
  • корректную работу при быстром движении курсора

Drag линий и полигонов

Перетаскивание точек усложняется, когда речь идёт о LineString или Polygon.

Принцип:

  • выделяется вершина (vertex)
  • перемещается только она
  • остальные координаты остаются неизменными

Пример структуры:

geometry: {
  type: 'Polygon',
  coordinates: [
    [
      [30, 50],
      [31, 50],
      [31, 51],
      [30, 50]
    ]
  ]
}

При drag изменяется конкретная вершина:

coords[ringIndex][vertexIndex] = [lng, lat];

Hit-testing вершин

Для сложных объектов используется ручной поиск ближайшей точки:

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;
}

Drag с использованием слоя «точек вершин»

Часто создаётся дополнительный слой:

  • основной слой: polygon
  • вспомогательный: vertices
map.addLayer({
  id: 'vertices',
  type: 'circle',
  source: 'polygon-source',
  filter: ['==', ['get', 'type'], 'vertex']
});

Это позволяет:

  • визуально выделять редактируемые точки
  • ускорять hit-test
  • разделять логику отображения и редактирования

Ограничение области перетаскивания

Иногда требуется ограничить перемещение:

  • в пределах bounding box
  • внутри полигона
  • по сетке

Пример ограничения:

function clampLngLat(lngLat) {
  return {
    lng: Math.max(29, Math.min(31, lngLat.lng)),
    lat: Math.max(49, Math.min(52, lngLat.lat))
  };
}

Snap-to-grid логика

Для привязки к сетке используется округление:

function snap(value, step) {
  return Math.round(value / step) * step;
}

Применение:

const lng = snap(lngLat.lng, 0.01);
const lat = snap(lngLat.lat, 0.01);

Это часто используется в редакторах картографических объектов.


Drag с инерцией (псевдо-физика)

Для более сложных 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);
  }
}

Работа с зумом во время drag

Одна из сложных проблем — изменение масштаба карты во время перетаскивания.

Решение:

  • фиксировать anchor point
  • пересчитывать координаты через unproject
map.on('zoom', () => {
  if (!dragging) return;

  const newLngLat = map.unproject(anchorPixel);
});

Синхронизация нескольких объектов

Если drag влияет на группу объектов:

  • используется общий state store
  • координаты обновляются батчем
const updated = features.map(f => {
  if (f.properties.group === activeGroup) {
    f.geometry.coordinates = shift(f.geometry.coordinates);
  }
  return f;
});

Производительность при большом количестве объектов

При десятках тысяч объектов:

  • избегается setData на каждый mousemove
  • используется throttle (16–32 ms)
  • применяется WebWorker для расчётов координат
let lastUpdate = 0;

map.on('mousemove', (e) => {
  const now = performance.now();
  if (now - lastUpdate < 16) return;

  lastUpdate = now;
  updateGeometry(e.lngLat);
});

Архитектурная модель drag-and-drop в MapLibre

Типичная структура:

  • Input layer (mouse/touch events)
  • Interaction manager (drag state machine)
  • Geometry store (GeoJSON или external state)
  • Renderer (MapLibre layers)

Такое разделение позволяет:

  • отделить UI от данных
  • масштабировать систему
  • добавлять сложные операции редактирования

Состояния взаимодействия

Любой drag можно представить как конечный автомат:

  • idle
  • hover
  • dragging
  • releasing
const state = {
  mode: 'idle',
  activeFeature: null
};

Это предотвращает конфликт событий mousemove, mouseup, mouseleave.


Обработка touch-событий

Для мобильных устройств используются:

  • touchstart
  • touchmove
  • touchend

MapLibre уже нормализует часть событий, но для кастомного drag требуется явная обработка.


Масштабируемые редакторы на MapLibre

На базе описанных механизмов строятся:

  • редакторы маршрутов
  • GIS-инструменты
  • системы разметки объектов
  • картографические конструкторы

Их основа всегда одна:

управление геометрией через pointer events + обновление GeoJSON + преобразование координат