Angular сервисы для карты

В Angular взаимодействие с Google Maps JavaScript API логично выносится в сервисный слой, поскольку карта представляет собой долгоживущий императивный объект, не вписывающийся в декларативную модель компонентов. Сервисы позволяют изолировать:

  • загрузку внешнего скрипта API
  • создание и управление экземпляром карты
  • работу с маркерами, слоями и событиями
  • синхронизацию состояния между компонентами

Такой подход формирует стабильную точку управления картографическим состоянием приложения и снижает связность UI-компонентов с внешним API.


Загрузка Google Maps JavaScript API через сервис

Первичная задача — корректная инициализация библиотеки. Скрипт Google Maps загружается динамически, чтобы избежать блокировки загрузки приложения и обеспечить контроль над повторной инициализацией.

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

@Injectable({ providedIn: 'root' })
export class GoogleMapsLoaderService {
  private static promise: Promise<typeof google> | null = null;

  load(apiKey: string): Promise<typeof google> {
    if (GoogleMapsLoaderService.promise) {
      return GoogleMapsLoaderService.promise;
    }

    GoogleMapsLoaderService.promise = new Promise((resolve, reject) => {
      if (typeof google !== 'undefined' && google.maps) {
        resolve(google);
        return;
      }

      const script = document.createElement('script');
      script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places,geometry`;
      script.async = true;
      script.defer = true;

      script.onl oad = () => resolve(google);
      script.oner ror = (err) => reject(err);

      document.head.appendChild(script);
    });

    return GoogleMapsLoaderService.promise;
  }
}

Ключевая особенность — использование статического кеша promise, предотвращающего повторную загрузку API при множественных обращениях из разных частей приложения.


Сервис управления экземпляром карты

После загрузки API требуется централизованное управление объектом google.maps.Map. В Angular это обычно реализуется через отдельный сервис, который хранит ссылку на карту и предоставляет методы взаимодействия.

import { Injectable, NgZone } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MapService {
  private map?: google.maps.Map;

  private centerSubject = new BehaviorSubject<google.maps.LatLngLiteral | null>(null);
  center$ = this.centerSubject.asObservable();

  constructor(private ngZone: NgZone) {}

  initMap(mapElement: HTMLElement, options: google.maps.MapOptions): google.maps.Map {
    this.map = new google.maps.Map(mapElement, options);

    this.map.addListener('center_changed', () => {
      const center = this.map?.getCenter();
      if (center) {
        this.ngZone.run(() => {
          this.centerSubject.next({
            lat: center.lat(),
            lng: center.lng()
          });
        });
      }
    });

    return this.map;
  }

  setCenter(lat: number, lng: number): void {
    if (!this.map) return;

    this.map.setCenter({ lat, lng });
  }

  getMap(): google.maps.Map | undefined {
    return this.map;
  }
}

Использование NgZone критично: события Google Maps происходят вне Angular Zone, и без явного возврата в зону изменения не будут корректно отражаться в UI.


Разделение ответственности: маркеры как отдельный сервис

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

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

@Injectable({ providedIn: 'root' })
export class MarkerService {
  private markers: google.maps.Marker[] = [];

  addMarker(map: google.maps.Map, position: google.maps.LatLngLiteral, title?: string): google.maps.Marker {
    const marker = new google.maps.Marker({
      position,
      map,
      title
    });

    this.markers.push(marker);
    return marker;
  }

  clearMarkers(): void {
    this.markers.forEach(m => m.setMap(null));
    this.markers = [];
  }

  setMarkers(map: google.maps.Map, positions: google.maps.LatLngLiteral[]): void {
    this.clearMarkers();
    positions.forEach(pos => this.addMarker(map, pos));
  }
}

Такой сервис позволяет централизованно управлять жизненным циклом объектов карты и упрощает очистку при смене маршрута или данных.


Интеграция с компонентами Angular

Компонент не должен напрямую работать с google.maps.Map или DOM-скриптами. Его роль ограничивается передачей контейнера и отображением состояния.

import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { GoogleMapsLoaderService } from './google-maps-loader.service';
import { MapService } from './map.service';

@Component({
  selector: 'app-map',
  template: `<div #mapContainer class="map-container"></div>`
})
export class MapComponent implements AfterViewInit {
  @ViewChild('mapContainer', { static: true }) mapElement!: ElementRef<HTMLDivElement>;

  constructor(
    private loader: GoogleMapsLoaderService,
    private mapService: MapService
  ) {}

  async ngAfterViewInit(): Promise<void> {
    const google = await this.loader.load('API_KEY');

    this.mapService.initMap(this.mapElement.nativeElement, {
      center: { lat: 51.1694, lng: 71.4491 },
      zoom: 10
    });
  }
}

Компонент остается максимально «тонким», делегируя всю логику сервисам.


Реактивное состояние карты

Для синхронизации состояния карты между различными частями приложения используется RxJS. Карта рассматривается как источник событий: изменение центра, масштаба, выбранных объектов.

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

export interface MapState {
  zoom: number;
  center: google.maps.LatLngLiteral;
}

@Injectable({ providedIn: 'root' })
export class MapStateService {
  private stateSubject = new BehaviorSubject<MapState | null>(null);
  state$ = this.stateSubject.asObservable();

  updateState(state: MapState): void {
    this.stateSubject.next(state);
  }
}

Интеграция с MapService позволяет синхронизировать изменения:

this.map.addListener('zoom_changed', () => {
  const zoom = this.map?.getZoom();
  const center = this.map?.getCenter();

  if (zoom && center) {
    this.ngZone.run(() => {
      this.stateService.updateState({
        zoom,
        center: {
          lat: center.lat(),
          lng: center.lng()
        }
      });
    });
  }
});

Обработка асинхронных событий и управление зонами Angular

Google Maps активно использует собственный event loop. Это приводит к ситуации, когда изменения происходят вне зоны Angular, что блокирует автоматическое обновление представления.

Использование NgZone.runOutsideAngular снижает нагрузку на change detection:

this.ngZone.runOutsideAngular(() => {
  this.map.addListener('idle', () => {
    const center = this.map.getCenter();
    console.log(center?.toJSON());
  });
});

При необходимости обновления UI управление возвращается в Angular зону.


Типизация и расширение API

Для повышения стабильности кода часто вводятся собственные интерфейсы поверх Google Maps API:

export interface MapPoint {
  lat: number;
  lng: number;
  label?: string;
}

export interface MapConfig {
  center: MapPoint;
  zoom: number;
  disableDefaultUI?: boolean;
}

Это позволяет абстрагироваться от внешней библиотеки и снижает связанность доменной логики с Google Maps.


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

Сервисы должны учитывать необходимость освобождения ресурсов при уничтожении компонентов или смене маршрутов. Особенно это важно при работе с подписками и слушателями событий карты.

destroyMap(): void {
  if (!this.map) return;

  google.maps.event.clearInstanceListeners(this.map);
  this.map = undefined;
}

Для маркеров аналогично требуется очистка, иначе возникают утечки памяти при динамической навигации.


Кэширование и повторное использование карты

В сложных приложениях карта может использоваться в нескольких модулях. Повторное создание экземпляра приводит к деградации производительности. Решение — хранение единственного экземпляра в сервисе.

private mapInstance?: google.maps.Map;

getOrCreateMap(element: HTMLElement, options: google.maps.MapOptions): google.maps.Map {
  if (this.mapInstance) {
    return this.mapInstance;
  }

  this.mapInstance = new google.maps.Map(element, options);
  return this.mapInstance;
}

Такой подход превращает сервис в singleton-менеджер состояния карты.


Масштабирование архитектуры: слои над Google Maps

По мере усложнения логики вводится дополнительный слой абстракции:

  • MapCoreService — работа с экземпляром карты
  • MarkerLayerService — управление маркерами
  • RouteLayerService — маршруты и Directions API
  • OverlayService — кастомные overlay элементы

Разделение на слои позволяет независимо расширять функциональность без модификации базового сервиса карты.


Тестируемость сервисов

Вынос логики в сервисы упрощает модульное тестирование. Вместо работы с DOM и API Google Maps можно мокировать зависимости.

const mockMap = jasmine.createSpyObj('Map', ['setCenter', 'getCenter']);

TestBed.configureTestingModule({
  providers: [
    MapService
  ]
});

Изоляция логики позволяет проверять поведение сервисов без реальной загрузки Google Maps API.


Управление конфигурацией через environment

API-ключ и параметры подключения не должны быть зашиты в код.

export const environment = {
  googleMapsApiKey: 'KEY',
  mapDefaultZoom: 12
};

Сервис загрузки API использует конфигурацию через DI или environment слой, что обеспечивает переносимость между окружениями.


Производительность при большом количестве объектов

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

  • кластеризация маркеров
  • виртуализация данных
  • ограничение количества активных listeners
  • дебаунс обновлений состояния

Кластеризация обычно выносится в отдельный сервис, который агрегирует точки и минимизирует количество DOM-объектов на карте.


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

Сервисы формируют единый слой событийной коммуникации:

  • изменение центра
  • изменение масштаба
  • добавление объектов
  • выбор элемента

Эта модель позволяет строить реактивную архитектуру, где карта становится источником потоков данных, а Angular-компоненты лишь потребляют их через Observable-интерфейсы.