Skip to content

Пример 205: Карта заправок + Leaflet + кластеризация

См. комментарии

Example205.tsx - чистый React-компонент все же имеет эффект внутри, т.к. инициализация карты имеет связь с жизненным циклом (монтированием/размонтированием) компонента.

tsx
import { useEffect, useRef } from 'react'
import baseClasses from '~/ui.common.module.scss'
import { ReactiveEngine, useReactiveValue } from '@pravosleva/reactive-engine'
import { MapLogic } from './service.MapLogic'
import clsx from 'clsx'

const engine = new ReactiveEngine()

export const MapExample = () => {
  // Инжектируем сервис логики, который управляет Leaflet и реактивным состоянием
  const logic = engine.inject(MapLogic)

  // Достаем состояние ресурса АЗС для декларативного отображения статуса в UI
  const { loading, data: stations, error } = useReactiveValue(logic.stationsResource)

  /**
   * Callback Ref для управления жизненным циклом DOM-ноды карты.
   * Вызывается автоматически React'ом при монтировании и размонтировании.
   */
  const mapRef = useRef<HTMLDivElement>(null)
  useEffect(() => {
    if (mapRef.current) {
      logic.initializeMap(mapRef.current)
    }

    // Передаем стрелочную функцию, чтобы избежать проблем с контекстом
    return () => {
      logic.destroyMap()
    }
  }, [logic])
  /* NOTE:
  1. Безопасный запуск эффекта: Перенос this.engine.effect из onInit в метод initializeMap гарантирует,
  что this.stationsResource уже объявлен и определен на момент старта подписки.
  2. Защита контекста this: Методы initializeMap и destroyMap
  объявлены как стрелочные функции (= () => {}).
  Теперь, как бы React ни вызывал эти методы в хуках очистки,
  контекст класса MapLogic никогда не потеряется.
  3. Жизненный цикл эффекта: При вызове destroyMap функция очистки эффекта this.effectCleanup()
  вызывается принудительно, вычищая подписку из ядра ReactiveEngine во избежание утечек памяти.
  */

  return (
    <div className={clsx(baseClasses.unit, baseClasses.stack2)}>
      <div className={baseClasses.absoluteUnitLabel}>
        Map Stations (BBox API + Debounce + Clusters)
      </div>

      {/* Контейнер для карты Leaflet */}
      <div
        ref={mapRef}
        style={{
          width: 'calc(100vw - 24px - 24px - 24px - 24px - 16px - 16px - 4px - 4px)',
          height: '400px',
          borderRadius: '8px',
          zIndex: 1,
        }}
      />

      {/* Декларативный вывод статуса загрузки */}
      <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
        <span>
          {loading
            ? '🟡 Получение АЗС...'
            : stations
              ? `🟢 Найдено АЗС: ${stations.length}`
              : error
                ? '🔴 Ошибка запроса'
                : '⚪'}
        </span>
        {error?.message && <em style={{ color: 'red' }}>{error.message}</em>}
      </div>
    </div>
  )
}

service.MapLogic.ts - логика работы с картой вынесена в отдельный сервис.

ts
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { AbstractService } from '@pravosleva/reactive-engine'

export interface Station {
  id: number
  name: string
  title: string
  lat: number
  lng: number
  slug: string
}

export class MapLogic extends AbstractService {
  public bbox = this.createSignal<string>('44.2097,33.2144,45.8785,34.9832', 'map:signal:bbox')

  public stationsResource = this.engine.resource(
    async (bboxValue, abortSignal) => {
      const url = new URL('/gdebenzin-vite-proxy/api/v1/stations', window.location.origin)
      url.searchParams.append('bbox', bboxValue)

      const res = await fetch(url.toString(), {
        signal: abortSignal,
        headers: { 'Accept': 'application/json' }
      })

      if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
      return res.json() as Promise<Station[]>
    },
    this.bbox,
    {
      name: 'map:resource:fetch-stations',
      validateBeforeFetch: (bboxValue) => !!bboxValue,
    }
  )

  private map: L.Map | null = null
  private clusterGroup: L.MarkerClusterGroup | null = null
  private debounceTimer: ReturnType<typeof setTimeout> | null = null
  // Храним функцию очистки эффекта, чтобы удалить её при уничтожении карты
  private effectCleanup: (() => void) | null = null

  /**
   * Инициализация карты из DOM-элемента
   */
  public initializeMap = (container: HTMLDivElement) => {
    if (this.map) return

    const [south, west, north, east] = this.bbox.value.split(',').map(Number)
    const bounds = L.latLngBounds([south, west], [north, east])

    this.map = L.map(container).fitBounds(bounds)

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: '© OpenStreetMap contributors'
    }).addTo(this.map)

    this.clusterGroup = L.markerClusterGroup({
      showCoverageOnHover: false,
      maxClusterRadius: 50,
    })
    this.map.addLayer(this.clusterGroup)

    this.map.on('moveend', this.handleMapMoveEnd)

    // Создаем реактивный эффект ОДИН раз, когда карта и все свойства класса точно готовы
    if (!this.effectCleanup) {
      this.effectCleanup = this.engine.effect(() => {
        const stations = this.stationsResource.data
        this.renderMarkers(stations)
      }, 'map:effect:sync-markers')
    } else {
      // Если эффект уже был создан ранее, просто принудительно синхронизируем текущее состояние
      this.renderMarkers(this.stationsResource.data)
    }
  }

  /**
   * Очистка карты при размонтировании
   */
  public destroyMap = () => {
    if (this.debounceTimer) clearTimeout(this.debounceTimer)

    // Уничтожаем реактивный эффект, чтобы он не висел в памяти движка
    if (this.effectCleanup) {
      this.effectCleanup()
      this.effectCleanup = null
    }

    if (this.map) {
      this.map.off('moveend', this.handleMapMoveEnd)
      this.map.remove()
    }
    this.map = null
    this.clusterGroup = null
  }

  private renderMarkers(stations: Station[] | null) {
    if (!this.clusterGroup || !stations || !Array.isArray(stations)) return

    this.clusterGroup.clearLayers()

    const newMarkers = stations
      .filter(station => station.lat && station.lng)
      .map(station => {
        return L.marker([station.lat, station.lng])
          .bindPopup(`<b>${station.title || station.name}</b><br>ID: ${station.id}`)
      })

    this.clusterGroup.addLayers(newMarkers)
  }

  private handleMapMoveEnd = () => {
    if (!this.map) return
    if (this.debounceTimer) clearTimeout(this.debounceTimer)

    this.debounceTimer = setTimeout(() => {
      if (!this.map) return
      const currentBounds = this.map.getBounds()
      const southWest = currentBounds.getSouthWest()
      const northEast = currentBounds.getNorthEast()

      const bboxString = [
        southWest.lat.toFixed(6),
        southWest.lng.toFixed(6),
        northEast.lat.toFixed(6),
        northEast.lng.toFixed(6)
      ].join(',')

      this.bbox.value = bboxString
    }, 300)
  }
}