import { useEffect, useRef } from 'react'; 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 type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap'; import { gpsClusterHtml, gpsClusterListHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html'; /** * Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка, * fallback для Яндекс.Карт). Чистый Leaflet без react-leaflet: * карта создаётся один раз на ref-контейнере, слои перестраиваются эффектами. */ const OSM_TILES_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const OSM_ATTRIBUTION = '© OpenStreetMap contributors'; const MOSCOW: [number, number] = [55.755864, 37.617698]; const DEFAULT_COLOR = '#3b82f6'; interface OsmMapProps { /** Центр [lat, lng]; по умолчанию — Москва */ center?: [number, number]; zoom?: number; /** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */ bounds?: [[number, number], [number, number]]; markers?: GpsMapMarker[]; polygons?: GpsMapPolygon[]; polylines?: GpsMapPolyline[]; onMapClick?: (lat: number, lng: number) => void; className?: string; } /** Опции маркера с нашим id (для списка кластера) */ type GpsMarkerOptions = L.MarkerOptions & { gpsMarkerId?: string | number }; export function OsmMap({ center, zoom = 10, bounds, markers = [], polygons = [], polylines = [], onMapClick, className, }: OsmMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const markersLayerRef = useRef(null); const clusterLayerRef = useRef(null); const featuresLayerRef = useRef(null); // Колбэки кликов и данные маркеров — через ref, чтобы не пересоздавать слои // при смене идентичности массивов (react-query рефетчи) const markersByIdRef = useRef(new Map()); const onMapClickRef = useRef(onMapClick); useEffect(() => { markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); onMapClickRef.current = onMapClick; }); // Создание карты (один раз) useEffect(() => { if (!containerRef.current || mapRef.current) return; const map = L.map(containerRef.current).setView(center ?? MOSCOW, zoom); L.tileLayer(OSM_TILES_URL, { attribution: OSM_ATTRIBUTION, maxZoom: 19 }).addTo(map); markersLayerRef.current = L.layerGroup().addTo(map); featuresLayerRef.current = L.layerGroup().addTo(map); // Кластер маркеров объектов (vehicle/person): при отдалении перекрывающиеся // маркеры схлопываются в кружок с числом. Клик по кластеру — popup // со списком объектов (обработчик ниже), а не зум. const clusterLayer = L.markerClusterGroup({ iconCreateFunction: (cluster) => L.divIcon({ html: gpsClusterHtml(cluster.getChildCount()), className: '', iconSize: [0, 0] }), zoomToBoundsOnClick: false, spiderfyOnMaxZoom: false, showCoverageOnHover: false, }).addTo(map); clusterLayerRef.current = clusterLayer; clusterLayer.on('clusterclick', (e: L.LeafletEvent) => { const cluster = (e as L.LeafletEvent & { layer?: L.MarkerCluster }).layer; const latlng = (e as L.LeafletEvent & { latlng?: L.LatLng }).latlng; if (!cluster || !latlng) return; const items = cluster .getAllChildMarkers() .map((cm: L.Marker) => (cm.options as GpsMarkerOptions).gpsMarkerId) .map((id: string | number | undefined) => (id !== undefined ? markersByIdRef.current.get(String(id)) : undefined)) .filter((m: GpsMapMarker | undefined): m is GpsMapMarker => !!m); if (items.length === 0) return; L.popup({ maxWidth: 260 }).setLatLng(latlng).setContent(gpsClusterListHtml(items)).openOn(map); }); map.on('click', (e: L.LeafletMouseEvent) => { onMapClickRef.current?.(e.latlng.lat, e.latlng.lng); }); // Делегированный клик по пункту списка кластера в popup // (контент popup рендерится внутри контейнера карты) map.getContainer().addEventListener('click', (ev) => { const el = (ev.target as HTMLElement | null)?.closest?.('[data-gps-marker-id]') as HTMLElement | null; if (!el) return; const marker = markersByIdRef.current.get(el.dataset.gpsMarkerId || ''); if (marker?.onClick) { marker.onClick(); map.closePopup(); } }); mapRef.current = map; return () => { map.remove(); mapRef.current = null; markersLayerRef.current = null; clusterLayerRef.current = null; featuresLayerRef.current = null; }; // Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Перемещение viewport только при реальном изменении center/zoom/bounds, // чтобы рефетчи не сбрасывали позицию, которую двигает пользователь const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`; const lastViewKeyRef = useRef(null); useEffect(() => { const map = mapRef.current; if (!map || lastViewKeyRef.current === viewKey) return; lastViewKeyRef.current = viewKey; if (bounds) { map.fitBounds(bounds); } else if (center) { map.setView(center, zoom); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewKey]); // Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в ref) const markersKey = JSON.stringify( markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]) ); useEffect(() => { const layer = markersLayerRef.current; const clusterLayer = clusterLayerRef.current; if (!layer || !clusterLayer) return; layer.clearLayers(); clusterLayer.clearLayers(); for (const m of markers) { // Якорь иконки: блок фиксированной ширины позиционируется так, чтобы // точка привязки совпала с координатой (кликабельная область = видимой) const layout = gpsMarkerLayout(m); const leafletMarker = L.marker([m.lat, m.lng], { icon: L.divIcon({ html: gpsMarkerHtml(m), className: '', iconSize: [layout.width, layout.height], iconAnchor: [layout.anchorX, layout.anchorY], }), zIndexOffset: m.zIndex ?? 0, gpsMarkerId: m.id, } as GpsMarkerOptions); if (m.onClick) { leafletMarker.on('click', () => { markersByIdRef.current.get(String(m.id))?.onClick?.(); }); } // Попап по клику (точки трека: время/скорость/курс) if (m.popup) { leafletMarker.bindPopup(m.popup); } // Кластерим только объекты; dot-маркеры и noCluster — в обычный слой const clusterable = (m.kind === 'vehicle' || m.kind === 'person') && !m.noCluster; if (clusterable) { leafletMarker.addTo(clusterLayer); } else { leafletMarker.addTo(layer); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [markersKey]); // Полигоны и полилинии const featuresKey = JSON.stringify([ polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]), polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]), ]); useEffect(() => { const layer = featuresLayerRef.current; if (!layer) return; layer.clearLayers(); for (const p of polygons) { if (p.points.length < 3) continue; const color = p.color || DEFAULT_COLOR; L.polygon(p.points, { color, weight: p.strokeWidth ?? 2, fillColor: color, fillOpacity: p.fillOpacity ?? 0.15, }).addTo(layer); } for (const l of polylines) { if (l.points.length < 2) continue; L.polyline(l.points, { color: l.color || DEFAULT_COLOR, weight: l.width ?? 4, dashArray: l.dashed ? '8 6' : undefined, }).addTo(layer); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [featuresKey]); return
; }