Закрытие попапов

Поведение всплывающих окон в MapLibre GL JS определяется комбинацией встроенных опций, DOM-событий и пользовательской логики управления состоянием. Закрытие попапов становится критичным элементом интерфейса при работе с интерактивными картами, поскольку напрямую влияет на читаемость слоя данных, предсказуемость взаимодействий и отсутствие визуального «засорения» карты.

Встроенный объект Popup предоставляет стандартные способы завершения его отображения. Основной метод управления жизненным циклом — remove(), который удаляет DOM-элемент и отвязывает его от карты.

const popup = new maplibregl.Popup()
  .setLngLat([30.5, 50.5])
  .setHTML('<b>Точка интереса</b>')
  .addTo(map);

// закрытие
popup.remove();

Этот вызов является финальной точкой жизненного цикла попапа: после него объект перестаёт участвовать в рендере и больше не реагирует на события карты.


Автоматическое закрытие по клику на карту

Наиболее распространённый сценарий — закрытие при клике вне попапа. Поведение управляется опцией:

const popup = new maplibregl.Popup({
  closeOnClick: true
});

При closeOnClick: true библиотека автоматически подписывается на событие клика по карте и вызывает remove() у активных попапов.

При closeOnClick: false управление полностью передаётся разработчику, что требуется в интерфейсах с высокой плотностью интерактивных элементов.


Закрытие при перемещении карты

Опция closeOnMove связывает жизненный цикл попапа с навигацией по карте:

const popup = new maplibregl.Popup({
  closeOnMove: true
});

Любое событие pan/zoom приводит к удалению попапа. Такой подход используется при отображении краткосрочной информации, которая теряет актуальность при смене viewport.

Внутри реализуется подписка на события move, zoom и rotate, после чего вызывается remove().


Ручное управление множественными попапами

В сложных интерфейсах часто требуется контроль над тем, какие попапы остаются открытыми. Распространённая архитектура — хранение ссылок на активные экземпляры:

const popups = [];

map.on('click', (e) => {
  const popup = new maplibregl.Popup()
    .setLngLat(e.lngLat)
    .setHTML('Данные объекта')
    .addTo(map);

  popups.push(popup);
});

Для закрытия всех попапов используется итерация:

popups.forEach(p => p.remove());
popups.length = 0;

Такой подход особенно важен при динамическом создании окон на основе слоёв или кластеров.


Синглтон-попап: предотвращение наложения окон

Часто применяется стратегия единственного активного попапа. Она устраняет необходимость управлять массивом экземпляров:

let activePopup = null;

map.on('click', (e) => {
  if (activePopup) {
    activePopup.remove();
  }

  activePopup = new maplibregl.Popup()
    .setLngLat(e.lngLat)
    .setHTML('Единственный popup')
    .addTo(map);
});

В такой модели закрытие нового попапа автоматически приводит к уничтожению предыдущего, что снижает когнитивную нагрузку интерфейса и упрощает контроль состояния.


Проблема всплытия событий и мгновенного закрытия

При добавлении кастомных обработчиков внутри popup DOM может возникнуть ситуация, когда клик внутри окна приводит к его закрытию. Это связано с распространением события на карту.

Корректное решение — остановка всплытия:

const popupContent = document.createElement('div');
popupContent.innerHTML = '<button id="btn">OK</button>';

popupContent.addEventListener('click', (e) => {
  e.stopPropagation();
});

Без этого клики по элементам интерфейса могут интерпретироваться как клик по карте, активируя closeOnClick.


Кастомная кнопка закрытия

Хотя стандартный popup уже содержит крестик, иногда требуется собственная логика закрытия:

const popup = new maplibregl.Popup({ closeButton: false })
  .setLngLat([30, 50])
  .setHTML(`
    <div class="card">
      <button id="close">Закрыть</button>
      <div>Контент</div>
    </div>
  `)
  .addTo(map);

document.getElementById('close').addEventListener('click', () => {
  popup.remove();
});

При таком подходе управление жизненным циклом полностью переносится в DOM-логику содержимого.


Закрытие по клавише Escape

Нативное поведение ESC может быть реализовано вручную, так как библиотека не всегда навязывает глобальные обработчики:

const popup = new maplibregl.Popup().setHTML('ESC закрывает');

function onKeyDown(e) {
  if (e.key === 'Escape') {
    popup.remove();
    window.removeEventListener('keydown', onKeyDown);
  }
}

window.addEventListener('keydown', onKeyDown);

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


Закрытие при клике на маркер и переключение состояний

При использовании Marker попап часто привязан к нему напрямую:

const marker = new maplibregl.Marker()
  .setLngLat([30, 50])
  .setPopup(new maplibregl.Popup().setHTML('Маркер'))
  .addTo(map);

При повторном клике логика закрытия зависит от состояния: библиотека автоматически переключает открытое состояние, но при кастомной реализации требуется явное управление:

marker.getPopup().remove();

Закрытие при смене слоя или данных

В динамических приложениях карта часто обновляет source/layer, что делает попапы неактуальными. В таких случаях используется централизованное удаление:

map.on('data', () => {
  if (activePopup) {
    activePopup.remove();
    activePopup = null;
  }
});

Это предотвращает ситуацию, когда попап ссылается на уже удалённые геометрии.


Закрытие при unmount в SPA

В React/Vue/Angular приложениях карта может уничтожаться при смене маршрута. Попапы должны быть очищены вместе с картой:

function destroyMap() {
  if (activePopup) {
    activePopup.remove();
    activePopup = null;
  }

  map.remove();
}

Игнорирование этого шага приводит к сохранению DOM-узлов вне контейнера и потенциальным утечкам памяти.


Приоритеты закрытия и конфликт опций

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

  1. Явный вызов popup.remove()
  2. closeOnClick
  3. closeOnMove
  4. Кастомные обработчики DOM

Если вызывается remove(), остальные механизмы фактически теряют значение, так как объект уже удалён из DOM-дерева карты.


Асинхронные сценарии и гонки состояний

При загрузке данных асинхронно возможна ситуация, когда попап закрывается и повторно открывается до завершения запроса:

let requestId = 0;

map.on('click', async (e) => {
  const id = ++requestId;

  const data = await fetchData(e.lngLat);

  if (id !== requestId) return;

  new maplibregl.Popup()
    .setLngLat(e.lngLat)
    .setHTML(data)
    .addTo(map);
});

Такой контроль предотвращает появление «устаревших» попапов после смены состояния.


Поведение при зумах и анимациях

При анимации камеры закрытие попапов может происходить до завершения движения карты. Это особенно заметно при flyTo. Используется привязка к событиям:

map.on('movestart', () => {
  if (activePopup) activePopup.remove();
});

Таким образом попап синхронизируется с визуальным состоянием карты и не «плавает» поверх переходов.


Очистка ресурсов и предотвращение утечек

Каждый попап создаёт DOM-узлы и event listeners. При долгоживущих картах важно явно разрывать связи:

popup.remove();
popup = null;

При отсутствии очистки в SPA-навигации постепенно накапливаются «осиротевшие» элементы, которые уже не отображаются, но остаются в памяти.


Итоговая модель поведения закрытия

Закрытие попапов в MapLibre GL JS строится на сочетании трёх уровней:

  • системные опции (closeOnClick, closeOnMove)
  • явное управление жизненным циклом (remove())
  • пользовательская DOM-логика (кнопки, клавиши, события)

Стабильная архитектура всегда опирается на явное управление экземплярами, минимизируя зависимость от автоматических механизмов и обеспечивая предсказуемость состояния интерфейса карты.