import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import type { MonitoredEntity } from '@/core/types'; import { useTheme } from '@/core/theme'; // Базовые слои карт (переключатель в углу). 2ГИС и Яндекс — для РФ. function baseLayers(): Record { return { 'Тёмная': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', { attribution: '© OSM · © CARTO', maxZoom: 19, subdomains: 'abcd' }), 'Светлая': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', { attribution: '© OSM · © CARTO', maxZoom: 19, subdomains: 'abcd' }), 'OSM': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }), '2ГИС': L.tileLayer('https://tile{s}.maps.2gis.com/tiles?x={x}&y={y}&z={z}', { attribution: '© 2ГИС', maxZoom: 18, subdomains: '0123' }), 'Яндекс': L.tileLayer('https://core-renderer-tiles.maps.yandex.net/tiles?l=map&x={x}&y={y}&z={z}&scale=1&lang=ru_RU', { attribution: '© Яндекс Карты', maxZoom: 19 }), }; } function color(e: MonitoredEntity, colors?: Record): string { if (colors?.[e.id]) return colors[e.id]; if (!e.online) return '#64748B'; if (e.type === 'energy') return '#0EA5E9'; if (e.type === 'network') return '#A78BFA'; return '#22C55E'; } function makeIcon(e: MonitoredEntity, follow: boolean, colors?: Record, glyphs?: Record) { const c = color(e, colors); const glyph = glyphs?.[e.id]; if (glyph) { // Маркер с пиктограммой (напр. ГБР ГЛОНАСС-авто на пульте ПЦН) const ring = follow ? `box-shadow:0 0 0 4px ${c}66, 0 0 0 8px ${c}22;` : `box-shadow:0 0 0 3px ${c}40;`; return L.divIcon({ className: '', html: `
${glyph}
`, iconSize: [26, 26], iconAnchor: [13, 13], popupAnchor: [0, -14], }); } const ring = follow ? `box-shadow:0 0 0 4px ${c}66, 0 0 0 8px ${c}22;` : e.online ? `box-shadow:0 0 0 4px ${c}33;` : ''; return L.divIcon({ className: '', html: `
`, iconSize: [14, 14], iconAnchor: [7, 7], popupAnchor: [0, -10], }); } export function MapView({ entities, selectedId, followId, onSelect, className, tracks, trackIds, trackColors, links, colors, glyphs, cursor, circles, events, }: { entities: MonitoredEntity[]; selectedId: string; followId: string | null; onSelect: (id: string) => void; className?: string; tracks?: Record>; trackIds?: string[]; trackColors?: Record; links?: Array<{ id: string; from: [number, number]; to: [number, number]; style: 'solid' | 'dash' | 'flow'; color: string }>; colors?: Record; // переопределение цвета маркера по id объекта (ПЦН: тревога/охрана) glyphs?: Record; // HTML-пиктограмма внутри маркера по id (ПЦН: ГБР ГЛОНАСС-авто) cursor?: { lat: number; lon: number; label?: string } | null; // курсор плеера трека circles?: Array<{ id: string; lat: number; lon: number; radius: number; color: string; name?: string }>; // геозоны events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>; // дорожные события }) { const theme = useTheme((s) => s.theme); const elRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef>({}); const linesRef = useRef>({}); const linksRef = useRef>({}); const cursorRef = useRef(null); const circlesRef = useRef>({}); const eventsRef = useRef([]); useEffect(() => { if (!elRef.current || mapRef.current) return; const map = L.map(elRef.current, { center: [44.05, 43.06], zoom: 11, zoomControl: true }); const bases = baseLayers(); // По умолчанию — 2ГИС на всех разделах (переключение слоёв остаётся в контроле) (bases['2ГИС'] ?? bases[theme === 'dark' ? 'Тёмная' : 'Светлая']).addTo(map); L.control.layers(bases, undefined, { position: 'topright', collapsed: true }).addTo(map); mapRef.current = map; const pts = entities.filter((e) => e.lat != null && e.lon != null); pts.forEach((e) => { const m = L.marker([e.lat!, e.lon!], { icon: makeIcon(e, false, colors, glyphs) }).addTo(map); m.bindPopup(`
${e.name}
${e.online ? 'на связи' : 'нет связи'}
`, { maxWidth: 220 }); m.on('click', () => onSelect(e.id)); markersRef.current[e.id] = m; }); if (pts.length) map.fitBounds(L.latLngBounds(pts.map((e) => [e.lat!, e.lon!] as [number, number])).pad(0.4)); const t = setTimeout(() => map.invalidateSize(), 200); return () => { clearTimeout(t); map.remove(); mapRef.current = null; markersRef.current = {}; linesRef.current = {}; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { entities.forEach((e) => { const m = markersRef.current[e.id]; if (m && e.lat != null && e.lon != null) { m.setLatLng([e.lat, e.lon]); m.setIcon(makeIcon(e, followId === e.id, colors, glyphs)); } }); const map = mapRef.current; if (map && followId) { const f = entities.find((x) => x.id === followId); if (f && f.lat != null && f.lon != null) map.panTo([f.lat, f.lon], { animate: true, duration: 0.5 }); } }, [entities, followId, colors, glyphs]); useEffect(() => { const map = mapRef.current; if (!map || !followId) return; const f = entities.find((x) => x.id === followId); if (f && f.lat != null && f.lon != null) map.setView([f.lat, f.lon], Math.max(map.getZoom(), 15), { animate: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [followId]); useEffect(() => { const map = mapRef.current; const e = entities.find((x) => x.id === selectedId); if (!map || !e || e.lat == null || e.lon == null || followId === selectedId) return; map.flyTo([e.lat, e.lon], 15, { duration: 0.6 }); markersRef.current[e.id]?.openPopup(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId]); useEffect(() => { const map = mapRef.current; if (!map) return; const ids = (trackIds || []).filter(Boolean); Object.keys(linesRef.current).forEach((id) => { if (!ids.includes(id)) { map.removeLayer(linesRef.current[id]); delete linesRef.current[id]; } }); ids.forEach((id) => { const pts = (tracks || {})[id]; if (!pts || pts.length < 2) return; const baseId = id.startsWith('trip-') ? id.slice(5) : id; const c = trackColors?.[id] ?? trackColors?.[baseId] ?? (followId === id ? '#22C55E' : '#0EA5E9'); if (linesRef.current[id]) linesRef.current[id].setLatLngs(pts).setStyle({ color: c }); else linesRef.current[id] = L.polyline(pts, { color: c, weight: 3, opacity: 0.85 }).addTo(map); }); }, [tracks, trackIds, followId, trackColors]); // Связи между узлами (визуализация связанности): сплошная / пунктир / анимированный пунктир useEffect(() => { const map = mapRef.current; if (!map) return; const cur = links || []; const ids = new Set(cur.map((l) => l.id)); Object.keys(linksRef.current).forEach((id) => { if (!ids.has(id)) { map.removeLayer(linksRef.current[id]); delete linksRef.current[id]; } }); cur.forEach((l) => { if (linksRef.current[l.id]) map.removeLayer(linksRef.current[l.id]); linksRef.current[l.id] = L.polyline([l.from, l.to], { color: l.color, weight: l.style === 'solid' ? 3.5 : 2.5, opacity: 0.9, dashArray: l.style === 'solid' ? undefined : '8 10', className: l.style === 'flow' ? 'link-flow' : '', }).addTo(map); }); }, [links]); // Курсор плеера трека — отдельный маркер, движется по маршруту при воспроизведении useEffect(() => { const map = mapRef.current; if (!map) return; if (!cursor) { if (cursorRef.current) { map.removeLayer(cursorRef.current); cursorRef.current = null; } return; } const icon = L.divIcon({ className: '', html: `
`, iconSize: [18, 18], iconAnchor: [9, 9], }); if (!cursorRef.current) { cursorRef.current = L.marker([cursor.lat, cursor.lon], { icon, zIndexOffset: 1000 }).addTo(map); } else { cursorRef.current.setLatLng([cursor.lat, cursor.lon]); } if (cursor.label) cursorRef.current.bindTooltip(cursor.label, { permanent: false, direction: 'top' }); }, [cursor]); // Геозоны (круги): рисуем/обновляем/удаляем useEffect(() => { const map = mapRef.current; if (!map) return; const cur = circles || []; const ids = new Set(cur.map((c) => c.id)); Object.keys(circlesRef.current).forEach((id) => { if (!ids.has(id)) { map.removeLayer(circlesRef.current[id]); delete circlesRef.current[id]; } }); cur.forEach((c) => { const existing = circlesRef.current[c.id]; if (existing) { existing.setLatLng([c.lat, c.lon]).setRadius(c.radius).setStyle({ color: c.color, fillColor: c.color }); } else { const circle = L.circle([c.lat, c.lon], { radius: c.radius, color: c.color, fillColor: c.color, fillOpacity: 0.12, weight: 2 }).addTo(map); if (c.name) circle.bindTooltip(c.name, { permanent: false, direction: 'center' }); circlesRef.current[c.id] = circle; } }); }, [circles]); // Дорожные события на треке (старт/стоп/стоянка/превышение) useEffect(() => { const map = mapRef.current; if (!map) return; eventsRef.current.forEach((m) => map.removeLayer(m)); eventsRef.current = []; (events || []).forEach((e) => { const icon = L.divIcon({ className: '', html: `
${e.char}
`, iconSize: [18, 18], iconAnchor: [9, 9], popupAnchor: [0, -12], }); const m = L.marker([e.lat, e.lon], { icon, zIndexOffset: 500 }).addTo(map); m.bindPopup(`
${e.label}
${e.sub ? `
${e.sub}
` : ''}`, { maxWidth: 220 }); eventsRef.current.push(m); }); }, [events]); useEffect(() => { const onResize = () => mapRef.current?.invalidateSize(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return
; }