Создание пользовательских контролов

Пользовательские контролы в MapLibre GL JS реализуются через интерфейс IControl, который определяет жизненный цикл элемента управления и его взаимодействие с картой. Любой контрол — это объект, который внедряется в DOM-контейнер карты и управляется через стандартные методы.

Базовая идея: контрол не «рисуется» библиотекой напрямую, а создаётся разработчиком как DOM-компонент с управляемыми точками подключения.

Интерфейс включает:

  • onAdd(map) — вызывается при добавлении контрола на карту
  • onRemove() — вызывается при удалении контрола
  • getDefaultPosition?() — определяет позицию по умолчанию

Минимальная реализация пользовательского контрола

Структура базового контрола строится вокруг DOM-элемента, который возвращается в onAdd.

class SimpleButtonControl {
    onAdd(map) {
        this._map = map;

        this._container = document.createElement('div');
        this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';

        this._button = document.createElement('button');
        this._button.type = 'button';
        this._button.textContent = '+';

        this._container.appendChild(this._button);

        this._button.addEventListener('click', () => {
            this._map.zoomIn();
        });

        return this._container;
    }

    onRemove() {
        this._container.remove();
        this._map = undefined;
    }
}

Ключевой момент: MapLibre GL JS ожидает, что onAdd вернёт DOM-узел, который будет встроен в контейнер карты.


Позиционирование контрола на карте

Контролы размещаются в одной из стандартных зон:

  • top-left
  • top-right
  • bottom-left
  • bottom-right

Определение позиции через getDefaultPosition:

class ZoomLabelControl {
    onAdd(map) {
        this._map = map;

        this._container = document.createElement('div');
        this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';

        this._label = document.createElement('div');
        this._label.textContent = `Zoom: ${map.getZoom().toFixed(2)}`;

        this._container.appendChild(this._label);

        this._map.on('zoom', this._update.bind(this));

        return this._container;
    }

    getDefaultPosition() {
        return 'top-right';
    }

    _update() {
        this._label.textContent = `Zoom: ${this._map.getZoom().toFixed(2)}`;
    }

    onRemove() {
        this._map.off('zoom', this._update);
        this._container.remove();
        this._map = undefined;
    }
}

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

Контрол часто подписывается на события карты:

  • move
  • zoom
  • rotate
  • load

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

Пример:

this._map.on('move', this._onMove);
this._map.on('zoom', this._onZoom);

Отписка:

this._map.off('move', this._onMove);
this._map.off('zoom', this._onZoom);

Блокировка взаимодействия карты

Контролы могут конфликтовать с перетаскиванием карты. Для предотвращения этого используется:

this._container.addEventListener('mousedown', (e) => {
    e.stopPropagation();
});

Для сенсорных устройств:

this._container.addEventListener('touchstart', (e) => {
    e.stopPropagation();
});

Это предотвращает захват жестов картой.


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

MapLibre GL JS использует стандартные классы:

  • maplibregl-ctrl
  • maplibregl-ctrl-group
  • maplibregl-ctrl-icon

Пример CSS:

.maplibregl-ctrl.my-custom-control {
    background: white;
    border-radius: 6px;
    padding: 6px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}

.my-custom-control button {
    border: none;
    background: transparent;
    cursor: pointer;
    font-size: 14px;
}

Контрол с пользовательской логикой (поиск)

Контрол может содержать сложные UI-компоненты:

class SearchControl {
    onAdd(map) {
        this._map = map;

        this._container = document.createElement('div');
        this._container.className = 'maplibregl-ctrl';

        this._input = document.createElement('input');
        this._input.type = 'text';
        this._input.placeholder = 'Search...';

        this._container.appendChild(this._input);

        this._input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                this._search(this._input.value);
            }
        });

        return this._container;
    }

    _search(query) {
        // пользовательская логика поиска
        console.log('Searching:', query);
    }

    onRemove() {
        this._container.remove();
        this._map = undefined;
    }
}

Интеграция с внешними API и данными

Контролы часто выступают как UI-слой над API запросами.

Пример обновления слоя карты:

async _fetchData() {
    const res = await fetch('/api/points');
    const data = await res.json();

    this._map.getSource('points').setData(data);
}

Контрол может управлять источниками и слоями через map.getSource() и map.addLayer().


Контролы с несколькими состояниями

Контрол может иметь переключатели режимов:

class ModeControl {
    onAdd(map) {
        this._map = map;

        this._container = document.createElement('div');

        this._btn = document.createElement('button');
        this._btn.textContent = '3D OFF';

        this._enabled = false;

        this._btn.oncl ick = () => {
            this._enabled = !this._enabled;
            this._toggle();
        };

        this._container.appendChild(this._btn);

        return this._container;
    }

    _toggle() {
        this._map.easeTo({
            pitch: this._enabled ? 60 : 0,
            bearing: this._enabled ? -20 : 0
        });

        this._btn.textContent = this._enabled ? '3D ON' : '3D OFF';
    }

    onRemove() {
        this._container.remove();
        this._map = undefined;
    }
}

Работа с несколькими контролами

Контролы добавляются независимо:

map.addControl(new SimpleButtonControl(), 'top-left');
map.addControl(new SearchControl(), 'top-right');
map.addControl(new ModeControl(), 'bottom-right');

Порядок добавления влияет на вертикальное расположение внутри одного угла.


Кастомные контейнеры и сложные UI-структуры

Контрол может содержать вложенные компоненты:

  • панели
  • вкладки
  • списки слоёв
  • фильтры данных

Пример панели слоёв:

class LayersControl {
    onAdd(map) {
        this._map = map;

        this._container = document.createElement('div');
        this._container.className = 'maplibregl-ctrl layers-control';

        ['roads', 'buildings', 'water'].forEach(layer => {
            const btn = document.createElement('button');
            btn.textContent = layer;

            btn.oncl ick = () => {
                const visibility = map.getLayoutProperty(layer, 'visibility');
                map.setLayoutProperty(
                    layer,
                    'visibility',
                    visibility === 'visible' ? 'none' : 'visible'
                );
            };

            this._container.appendChild(btn);
        });

        return this._container;
    }

    onRemove() {
        this._container.remove();
        this._map = undefined;
    }
}

TypeScript-типизация контролов

В TypeScript используется интерфейс:

import type { Map, IControl } from 'maplibre-gl';

class TypedControl implements IControl {
    private map?: Map;
    private container!: HTMLElement;

    onAdd(map: Map): HTMLElement {
        this.map = map;

        this.container = document.createElement('div');
        return this.container;
    }

    onRemove(): void {
        this.container.remove();
        this.map = undefined;
    }

    getDefaultPosition?(): string {
        return 'top-left';
    }
}

Жизненный цикл и утечки памяти

Критические точки:

  • подписки на события карты
  • таймеры (setInterval, setTimeout)
  • внешние API-запросы

Корректный onRemove обязан очищать всё:

onRemove() {
    clearInterval(this._timer);
    this._map.off('move', this._handler);
    this._container.remove();
    this._map = undefined;
}

Доступность и клавиатурная навигация

Контролы должны поддерживать:

  • tabindex
  • aria-label
  • обработку keydown

Пример:

this._button.setAttribute('aria-label', 'Zoom in');
this._button.tabIndex = 0;

this._button.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
        this._map.zoomIn();
    }
});

Поведение при изменении размеров карты

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

  • пересчёт размеров
  • скрытие элементов при маленьком viewport
  • адаптивные панели

Архитектурные паттерны для больших контролов

При росте сложности используется разделение:

  • View (DOM)
  • Controller (логика)
  • Service (данные)

Контрол становится точкой интеграции, а не монолитом логики.

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

control/
  index.js
  view.js
  state.js
  api.js

Такой подход критичен при построении аналитических или GIS-интерфейсов поверх MapLibre GL JS.