Angular integration

Работа с картографическим рендерингом в Angular требует аккуратной интеграции императивной библиотеки в реактивную архитектуру фреймворка. Mapbox GL JS представляет собой низкоуровневый WebGL-движок, который напрямую управляет DOM-элементом карты, тогда как Angular строится вокруг компонентов, DI-контейнера и зоны отслеживания изменений. Основная задача интеграции — изолировать Mapbox от механизма change detection и обеспечить предсказуемый жизненный цикл.


Установка зависимостей и базовая конфигурация

Подключение Mapbox GL JS выполняется через npm:

npm install mapbox-gl

Дополнительно требуется типизация:

npm install --save-dev @types/mapbox-gl

Подключение CSS библиотеки критично, так как без него карта не будет отображаться корректно:

// angular.json
"styles": [
  "node_modules/mapbox-gl/dist/mapbox-gl.css",
  "src/styles.css"
]

Ключ доступа хранится в environment-файле:

// environment.ts
export const environment = {
  mapboxToken: 'YOUR_MAPBOX_TOKEN'
};

Архитектура интеграции через сервис

Прямое создание карты внутри компонента приводит к дублированию логики и сложностям тестирования. Более устойчивый подход — инкапсуляция работы с Mapbox в сервис.

import { Injectable } from '@angular/core';
import mapboxgl from 'mapbox-gl';
import { environment } from '../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class MapService {
  constructor() {
    (mapboxgl as any).accessToken = environment.mapboxToken;
  }

  createMap(container: string | HTMLElement, options?: mapboxgl.MapboxOptions) {
    return new mapboxgl.Map({
      container,
      style: 'mapbox://styles/mapbox/streets-v12',
      center: [0, 0],
      zoom: 2,
      ...options
    });
  }
}

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


Компонент карты и жизненный цикл Angular

Mapbox требует наличия DOM-узла. Поэтому инициализация выполняется в ngAfterViewInit, когда шаблон уже отрендерен.

import { Component, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import { MapService } from './map.service';
import mapboxgl from 'mapbox-gl';

@Component({
  selector: 'app-map',
  template: `<div #mapContainer class="map-container"></div>`,
  styles: [`
    .map-container {
      width: 100%;
      height: 500px;
    }
  `]
})
export class MapComponent implements AfterViewInit, OnDestroy {
  @ViewChild('mapContainer', { static: false }) mapContainer!: ElementRef;
  private map!: mapboxgl.Map;

  constructor(private mapService: MapService) {}

  ngAfterViewInit(): void {
    this.map = this.mapService.createMap(this.mapContainer.nativeElement, {
      center: [37.6173, 55.7558],
      zoom: 10
    });
  }

  ngOnDestroy(): void {
    if (this.map) {
      this.map.remove();
    }
  }
}

Ключевой момент — обязательный вызов map.remove(), иначе WebGL-контекст остаётся в памяти.


Управление Change Detection и Zone.js

Mapbox генерирует большое количество событий (move, render, zoom), которые не должны триггерить Angular CD без необходимости.

Оптимизация достигается через NgZone.runOutsideAngular:

import { NgZone } from '@angular/core';

constructor(private mapService: MapService, private zone: NgZone) {}

ngAfterViewInit(): void {
  this.zone.runOutsideAngular(() => {
    this.map = this.mapService.createMap(this.mapContainer.nativeElement);

    this.map.on('move', () => {
      const center = this.map.getCenter();
      console.log(center);
    });
  });
}

Использование runOutsideAngular снижает нагрузку и предотвращает лишние циклы проверки изменений.


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

Mapbox GL JS использует императивную модель добавления объектов на карту.

Добавление маркера

const marker = new mapboxgl.Marker()
  .setLngLat([37.6173, 55.7558])
  .addTo(this.map);

Удаление маркера

marker.remove();

Работа с источниками данных и слоями

Mapbox использует концепцию source и layer.

Добавление GeoJSON источника

this.map.on('load', () => {
  this.map.addSource('points', {
    type: 'geojson',
    data: {
      type: 'FeatureCollection',
      features: [
        {
          type: 'Feature',
          geometry: {
            type: 'Point',
            coordinates: [37.6173, 55.7558]
          },
          properties: {}
        }
      ]
    }
  });
});

Добавление слоя отображения

this.map.addLayer({
  id: 'points-layer',
  type: 'circle',
  source: 'points',
  paint: {
    'circle-radius': 6,
    'circle-color': '#ff0000'
  }
});

Реактивная интеграция через RxJS

Angular-архитектура часто требует реактивного обновления карты. Например, обновление данных через Observable.

this.dataService.points$.subscribe(points => {
  const source = this.map.getSource('points') as mapboxgl.GeoJSONSource;

  source.setData({
    type: 'FeatureCollection',
    features: points.map(p => ({
      type: 'Feature',
      geometry: {
        type: 'Point',
        coordinates: [p.lng, p.lat]
      },
      properties: {}
    }))
  });
});

Важно избегать пересоздания источника — только обновление через setData.


Управление размером карты и resize

Mapbox требует явного уведомления при изменении контейнера:

window.addEventListener('resize', () => {
  this.map.resize();
});

Более корректный вариант — использование ResizeObserver:

const observer = new ResizeObserver(() => {
  this.map.resize();
});

observer.observe(this.mapContainer.nativeElement);

Интеграция с Angular Router

При смене маршрута контейнер может уничтожаться, что требует очистки состояния карты.

this.router.events.subscribe(event => {
  if (this.map && event instanceof NavigationEnd) {
    this.map.remove();
  }
});

Чаще применяется локальная очистка в ngOnDestroy, но при сложных layout-сценариях требуется контроль маршрутов.


Директивный подход для переиспользования

Для масштабируемых приложений удобно вынести карту в директиву.

@Directive({
  selector: '[appMap]'
})
export class MapDirective implements AfterViewInit, OnDestroy {
  private map!: mapboxgl.Map;

  constructor(private el: ElementRef, private mapService: MapService) {}

  ngAfterViewInit(): void {
    this.map = this.mapService.createMap(this.el.nativeElement);
  }

  ngOnDestroy(): void {
    this.map?.remove();
  }
}

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

<div appMap class="map-container"></div>

Асинхронная инициализация и lazy loading

Mapbox GL JS имеет значительный вес, поэтому часто применяется динамический импорт:

async ngAfterViewInit() {
  const mapboxgl = await import('mapbox-gl');

  this.map = new mapboxgl.Map({
    container: this.mapContainer.nativeElement,
    style: 'mapbox://styles/mapbox/streets-v12'
  });
}

Это позволяет исключить библиотеку из начального бандла.


Кастомные контролы и расширение API

Mapbox поддерживает пользовательские контролы через интерфейс IControl.

class CustomControl {
  onAdd(map: mapboxgl.Map) {
    this.map = map;
    this.container = document.createElement('div');
    this.container.className = 'custom-control';
    this.container.textContent = 'Control';
    return this.container;
  }

  onRemove() {
    this.container.parentNode?.removeChild(this.container);
  }
}

Добавление:

this.map.addControl(new CustomControl(), 'top-right');

Типизация и работа с TypeScript

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

const source = this.map.getSource('points') as mapboxgl.GeoJSONSource;

Также важно учитывать, что события типизируются через MapLayerEventType:

this.map.on('click', 'points-layer', (e) => {
  console.log(e.features);
});

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

Основные узкие места:

  • частые вызовы setData
  • избыточные Angular change detection cycles
  • отсутствие очистки слоёв
  • утечки WebGL контекста

Рекомендуемые практики:

  • использовать runOutsideAngular
  • обновлять данные батчами
  • избегать пересоздания карты
  • контролировать жизненный цикл через OnDestroy

Многокартовые интерфейсы

При работе с несколькими экземплярами карт важно изолировать состояния:

const maps: mapboxgl.Map[] = [];

maps.push(
  this.mapService.createMap(container1)
);

maps.push(
  this.mapService.createMap(container2)
);

Каждая карта должна иметь независимые источники и слои, иначе возможны конфликты идентификаторов.


Обработка ошибок и устойчивость

Mapbox генерирует runtime-ошибки при:

  • неверном токене
  • недоступном стиле
  • повторном добавлении source/layer

Обработка:

this.map.on('error', (e) => {
  console.error('Mapbox error:', e.error);
});

Итоговая модель интеграции

Типовая архитектура Angular + Mapbox GL JS строится на трёх уровнях:

  • сервис (инициализация и конфигурация)
  • компонент или директива (DOM и lifecycle)
  • реактивный слой (RxJS для данных)

Такая структура обеспечивает предсказуемость поведения карты в рамках Angular-приложения и минимизирует конфликты между императивным и реактивным подходами.