Пример 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)
}
}