Определение кликов по объектам

В MapLibre GL JS обработка кликов строится вокруг событийной модели WebGL-карты, где взаимодействие с объектами реализуется через анализ координат курсора и выборку отрисованных геометрий на текущем кадре рендеринга.

Ключевая точка входа — событие:

map.on('click', (e) => {
  console.log(e.point);   // пиксельные координаты
  console.log(e.lngLat);  // географические координаты
});

Объект события содержит два фундаментальных источника данных:

  • e.point — координаты в пикселях относительно контейнера карты
  • e.lngLat — долгота и широта в момент клика

Эти данные сами по себе не определяют, по какому объекту произошёл клик. Для этого используется выборка объектов слоя.


Выбор объектов через queryRenderedFeatures

Основной механизм определения попадания клика в геометрию слоя — метод:

map.queryRenderedFeatures(point, options)

Простейший вариант:

map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point);

  console.log(features);
});

Возвращаемый массив содержит объекты GeoJSON, уже отфильтрованные по текущему состоянию рендера (видимость слоя, масштаб, фильтры стиля).

Фильтрация по слоям

На практике всегда ограничивается набор слоёв:

map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point, {
    layers: ['cities-layer']
  });

  if (!features.length) return;

  const feature = features[0];
  console.log(feature.properties);
});

Параметр layers критически важен для:

  • устранения пересечений между слоями
  • повышения производительности
  • предсказуемости результата

Приоритет объектов при пересечениях

При наличии нескольких слоёв в одной точке порядок выбора определяется порядком отрисовки слоёв в стиле карты.

const features = map.queryRenderedFeatures(e.point, {
  layers: ['roads', 'buildings', 'pois']
});

Приоритет задаётся массивом:

  • первый слой имеет наивысший приоритет
  • далее по убыванию

Это позволяет формировать строгую иерархию интерактивности.


Разделение клика по геометрическим типам

Результат queryRenderedFeatures зависит от типа слоя:

Point (символьные слои)

map.queryRenderedFeatures(e.point, {
  layers: ['points']
});

Используется для:

  • маркеров
  • POI
  • кластеров

LineString (линейные объекты)

Линейные объекты требуют учёта tolerance-подобной логики, поскольку клик редко попадает строго в пиксель линии.

MapLibre использует буферизацию hit-test на уровне рендера.


Polygon (полигоны)

Полигоны наиболее чувствительны к кликам, так как проверка осуществляется по области заполнения.


Определение курсора при наведении и клике

Для повышения интерактивности используется изменение курсора:

map.on('mousemove', (e) => {
  const features = map.queryRenderedFeatures(e.point, {
    layers: ['cities-layer']
  });

  map.getCanvas().style.cursor = features.length ? 'pointer' : '';
});

Это создаёт поведение, аналогичное DOM-элементам, но применённое к WebGL-слою.


Разделение логики hover и click

В интерактивных картах часто разделяются два события:

hover (mousemove)

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

click

  • используется для фиксации выбора
  • может открывать popups
  • выполняется реже

Пример разделения:

map.on('mousemove', (e) => {
  const f = map.queryRenderedFeatures(e.point, {
    layers: ['cities-layer']
  });

  map.setFeatureState(
    { source: 'cities', id: hoveredId },
    { hover: false }
  );

  if (f.length) {
    hoveredId = f[0].id;

    map.setFeatureState(
      { source: 'cities', id: hoveredId },
      { hover: true }
    );
  }
});

Использование feature state для кликов

Система feature-state позволяет хранить интерактивное состояние объектов без модификации источника данных.

map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point, {
    layers: ['cities-layer']
  });

  if (!features.length) return;

  const id = features[0].id;

  map.setFeatureState(
    { source: 'cities', id },
    { selected: true }
  );
});

Типичные состояния:

  • hover
  • selected
  • active
  • disabled

Работа с popup при клике

Классический сценарий — отображение всплывающего окна:

map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point, {
    layers: ['cities-layer']
  });

  if (!features.length) return;

  const feature = features[0];

  new maplibregl.Popup()
    .setLngLat(e.lngLat)
    .setHTML(`<strong>${feature.properties.name}</strong>`)
    .addTo(map);
});

Ключевой момент: lngLat берётся из события, а не из feature, чтобы избежать искажений при сложной геометрии.


Кластеры и обработка кликов

При использовании кластеризации поведение клика отличается: вместо конечного объекта возвращается кластер.

map.on('click', 'clusters', (e) => {
  const features = map.queryRenderedFeatures(e.point, {
    layers: ['clusters']
  });

  const clusterId = features[0].properties.cluster_id;

  map.getSource('points').getClusterExpansionZoom(
    clusterId,
    (err, zoom) => {
      if (err) return;

      map.easeTo({
        center: features[0].geometry.coordinates,
        zoom
      });
    }
  );
});

Здесь логика разделяется на два уровня:

  • кластер как интерактивный объект
  • декомпозиция кластера через source API

Обработка множественных объектов в одной точке

В одной точке могут находиться несколько объектов разных слоёв.

map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point);

  const grouped = features.reduce((acc, f) => {
    acc[f.layer.id] = acc[f.layer.id] || [];
    acc[f.layer.id].push(f);
    return acc;
  }, {});

  console.log(grouped);
});

Это используется для:

  • построения меню выбора объекта
  • приоритизации взаимодействия
  • диагностики пересечений

Ограничение области клика (hit tolerance)

Для линий и мелких объектов часто применяется расширенная зона захвата через фильтрацию:

map.queryRenderedFeatures(e.point, {
  layers: ['roads']
});

Дополнительно можно использовать искусственное расширение области:

const bbox = [
  [e.point.x - 5, e.point.y - 5],
  [e.point.x + 5, e.point.y + 5]
];

const features = map.queryRenderedFeatures(bbox, {
  layers: ['roads']
});

Предотвращение конфликтов клика между слоями

При наложении интерактивных слоёв используется строгая иерархия:

const clickableLayers = ['top-layer', 'middle-layer', 'base-layer'];

map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point, {
    layers: clickableLayers
  });

  if (!features.length) return;

  const topFeature = features[0];
});

Дополнительно применяется логика остановки дальнейшей обработки:

map.on('click', 'top-layer', (e) => {
  e.originalEvent.stopPropagation();
});

Производительность при массовых данных

При большом количестве объектов критично избегать полного перебора слоёв без фильтрации.

Оптимизационные приёмы:

  • всегда ограничивать layers
  • минимизировать число queryRenderedFeatures
  • использовать feature-state вместо перерасчёта стилей
  • избегать тяжёлых операций внутри обработчика клика

Привязка кликов к динамическим данным

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

map.getSource('cities').setData(newData);

обработчики кликов не требуют пересоздания, так как queryRenderedFeatures работает с актуальным состоянием рендера, а не с исходным набором данных.


Комбинация клика и анимации камеры

Интерактивные сценарии часто связывают выбор объекта с изменением положения карты:

map.on('click', (e) => {
  const features = map.queryRenderedFeatures(e.point, {
    layers: ['cities-layer']
  });

  if (!features.length) return;

  const coords = features[0].geometry.coordinates;

  map.flyTo({
    center: coords,
    zoom: 12
  });
});

Такой подход формирует связку:

  • hit-test → feature
  • feature → координаты
  • координаты → анимация камеры

Обработка кликов на кастомных слоях (custom layers)

При использовании WebGL custom layers стандартный queryRenderedFeatures может быть недоступен, и логика клика переносится на пользовательскую математику:

  • преобразование e.point в координаты сцены
  • ручная проверка пересечения
  • собственный hit-test алгоритм

Это расширяет модель взаимодействия за пределы стандартного слоя данных MapLibre.