Hover эффекты

Интерактивное поведение при наведении курсора является одним из ключевых механизмов взаимодействия с картографическими интерфейсами. В Mapbox GL JS hover-эффекты реализуются через событийную модель WebGL-рендерера и API слоёв, позволяя динамически изменять стиль объектов, отображать всплывающие окна, а также управлять состоянием фич без перерисовки всей сцены.


Событийная модель наведения курсора

Hover-логика в Mapbox GL JS строится вокруг событий мыши, привязанных к слоям карты:

  • mouseenter — курсор вошёл в область слоя
  • mousemove — движение курсора внутри слоя
  • mouseleave — курсор покинул слой
  • mouseover — аналогично mouseenter, но с особенностями всплытия событий
  • mouseout — аналогично mouseleave

Базовая структура обработки событий:

map.on('mouseenter', 'cities-layer', () => {
  map.getCanvas().style.cursor = 'pointer';
});

map.on('mouseleave', 'cities-layer', () => {
  map.getCanvas().style.cursor = '';
});

Изменение курсора является самым простым визуальным индикатором интерактивности слоя и часто используется как первый уровень hover-интеракции.


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

Одним из наиболее распространённых подходов является динамическое изменение paint-параметров слоя при наведении.

Пример: изменение цвета фичи

map.on('mousemove', 'cities-layer', (e) => {
  if (e.features.length > 0) {
    const feature = e.features[0];

    map.setPaintProperty('cities-layer', 'fill-color', [
      'case',
      ['==', ['get', 'id'], feature.properties.id],
      '#ffcc00',
      '#3388ff'
    ]);
  }
});

Такой подход работает, но имеет ограничение: он изменяет стиль всего слоя, а не отдельных фич логически. При большом количестве объектов может возникать избыточная переработка стиля.


Использование feature-state для hover-эффектов

Более производительный и масштабируемый способ — использование состояния фич.

Mapbox GL JS предоставляет механизм feature-state, который позволяет хранить временные состояния объектов без изменения исходного GeoJSON или стиля слоя.

Установка состояния при наведении

let hoveredId = null;

map.on('mousemove', 'cities-layer', (e) => {
  if (e.features.length > 0) {
    const feature = e.features[0];

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

    hoveredId = feature.id;

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

map.on('mouseleave', 'cities-layer', () => {
  if (hoveredId !== null) {
    map.setFeatureState(
      { source: 'cities', id: hoveredId },
      { hover: false }
    );
  }
  hoveredId = null;
});

Использование состояния в стиле слоя

map.addLayer({
  id: 'cities-layer',
  type: 'circle',
  source: 'cities',
  paint: {
    'circle-radius': 6,
    'circle-color': [
      'case',
      ['boolean', ['feature-state', 'hover'], false],
      '#ff3b3b',
      '#3b82f6'
    ]
  }
});

Определение фич под курсором

Hover-эффекты часто зависят от определения объекта под курсором. Для этого используется queryRenderedFeatures.

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

  if (!features.length) return;

  const feature = features[0];
});

Этот метод позволяет работать даже с перекрывающимися слоями и сложной визуализацией.


Всплывающие окна при наведении

Hover часто сопровождается отображением popup-элементов.

const popup = new mapboxgl.Popup({
  closeButton: false,
  closeOnClick: false
});

map.on('mousemove', 'cities-layer', (e) => {
  const feature = e.features[0];

  map.getCanvas().style.cursor = 'pointer';

  popup
    .setLngLat(feature.geometry.coordinates)
    .setHTML(`<strong>${feature.properties.name}</strong>`)
    .addTo(map);
});

map.on('mouseleave', 'cities-layer', () => {
  map.getCanvas().style.cursor = '';
  popup.remove();
});

Особенность подхода заключается в необходимости синхронизации popup с движением мыши и предотвращении «дрожания» при частом обновлении координат.


Разделение hover-логики для нескольких слоёв

В реальных приложениях часто присутствуют несколько интерактивных слоёв. Hover-логика должна учитывать приоритеты слоёв.

const hoverLayers = ['cities-layer', 'regions-layer', 'roads-layer'];

hoverLayers.forEach((layer) => {
  map.on('mousemove', layer, (e) => {
    const feature = e.features[0];
    map.getCanvas().style.cursor = 'pointer';
  });

  map.on('mouseleave', layer, () => {
    map.getCanvas().style.cursor = '';
  });
});

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


Производительность hover-обработчиков

Hover-события могут вызываться десятки раз в секунду, поэтому критично минимизировать нагрузку:

  • избегать тяжёлых вычислений внутри mousemove
  • кэшировать feature.id
  • использовать feature-state вместо setPaintProperty
  • ограничивать обновления popup

Типичный анти-паттерн:

map.on('mousemove', (e) => {
  map.setPaintProperty('layer', 'fill-color', generateComplexEx * pression(e));
});

Такой подход приводит к постоянной переработке стиля слоя и ухудшению FPS.


Сравнение подходов к hover-эффектам

1. Через setPaintProperty

  • Простая реализация
  • Плохая масштабируемость
  • Перерисовка всего слоя

2. Через feature-state

  • Высокая производительность
  • Поддержка тысяч объектов
  • Чистая логика состояния

3. Через фильтрацию слоя

  • Использование filter для выделения фич
  • Подходит для статичных данных
  • Ограниченная гибкость

Hover на линиях и полигонах

Для сложных геометрий (линии, полигоны) hover часто требует увеличения области попадания:

map.addLayer({
  id: 'roads-hover',
  type: 'line',
  source: 'roads',
  paint: {
    'line-width': [
      'case',
      ['boolean', ['feature-state', 'hover'], false],
      6,
      2
    ],
    'line-color': '#ff9900'
  }
});

Такой подход создаёт визуальное «утолщение» линии при наведении, улучшая UX без изменения геометрии.


Комбинирование hover и click-состояний

Hover часто используется вместе с кликом, что требует разделения состояний:

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

И в стиле:

'circle-color': [
  'case',
  ['boolean', ['feature-state', 'selected'], false],
  '#10b981',
  ['boolean', ['feature-state', 'hover'], false],
  '#f59e0b',
  '#3b82f6'
]

Управление конфликтами событий

При быстром перемещении курсора возможны состояния гонки, когда несколько фич остаются в hover-состоянии. Решение заключается в централизованном хранении текущего hoveredId и обязательном сбросе предыдущего состояния перед установкой нового.


Hover и кастомные источники данных

При работе с GeoJSON источниками важно учитывать наличие id у каждой фичи:

map.addSource('cities', {
  type: 'geojson',
  data: geojsonData,
  generateId: true
});

Без id механизм feature-state становится неприменимым, что вынуждает использовать менее эффективные подходы.


Hover в связке с динамическими данными

При обновлении источников через setData hover-состояния могут сбрасываться, поэтому:

  • состояние hover хранится отдельно
  • после обновления источника выполняется повторное применение feature-state

Практика оптимальной архитектуры hover-логики

Типовая структура hover-обработки в сложных приложениях:

  • единый менеджер hover-состояния
  • кэширование текущего объекта
  • разделение визуального слоя и логики
  • использование feature-state как основного механизма
  • минимизация работы внутри событий мыши

Такой подход обеспечивает стабильную производительность даже при большом количестве интерактивных объектов на карте, построенной на Mapbox инфраструктуре.