274 lines
8.4 KiB
React
274 lines
8.4 KiB
React
import React, { useEffect, useRef, useState } from "react";
|
|
import L from "leaflet";
|
|
import "leaflet/dist/leaflet.css";
|
|
import "leaflet.heat";
|
|
import "./MapView.css";
|
|
|
|
// Fix Leaflet default marker icons
|
|
delete L.Icon.Default.prototype._getIconUrl;
|
|
L.Icon.Default.mergeOptions({
|
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
|
});
|
|
|
|
const MapView = ({
|
|
tnpLat,
|
|
tnpLon,
|
|
zones = [],
|
|
direction = null,
|
|
heatmapData = [],
|
|
showHeatmap = false
|
|
}) => {
|
|
const mapRef = useRef(null);
|
|
const mapInstanceRef = useRef(null);
|
|
const zonesLayerRef = useRef(null);
|
|
const heatmapLayerRef = useRef(null);
|
|
const tnpMarkerRef = useRef(null);
|
|
const directionArrowRef = useRef(null);
|
|
|
|
const [heatmapVisible, setHeatmapVisible] = useState(showHeatmap);
|
|
|
|
// Цвета зон из §5 ЦОУ палитры
|
|
const ZONE_COLORS = {
|
|
0: "#ff4d00", // Зона 1 (0-500м) - красный
|
|
1: "#ff9f1a", // Зона 2 (500-1500м) - оранжевый
|
|
2: "#ffd166", // Зона 3 (1500-2500м) - желтый
|
|
};
|
|
|
|
// Направления в градусы
|
|
const DIRECTION_ANGLES = {
|
|
N: 0,
|
|
NE: 45,
|
|
E: 90,
|
|
SE: 135,
|
|
S: 180,
|
|
SW: 225,
|
|
W: 270,
|
|
NW: 315,
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!mapRef.current || mapInstanceRef.current) return;
|
|
|
|
// Инициализация карты
|
|
const map = L.map(mapRef.current).setView([tnpLat || 53.9, tnpLon || 27.56], 13);
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: "© OpenStreetMap contributors",
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
mapInstanceRef.current = map;
|
|
|
|
return () => {
|
|
if (mapInstanceRef.current) {
|
|
mapInstanceRef.current.remove();
|
|
mapInstanceRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Обновление ТНП маркера
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current || !tnpLat || !tnpLon) return;
|
|
|
|
// Удаляем старый маркер
|
|
if (tnpMarkerRef.current) {
|
|
mapInstanceRef.current.removeLayer(tnpMarkerRef.current);
|
|
}
|
|
|
|
// Создаем красный маркер ТНП
|
|
const tnpIcon = L.divIcon({
|
|
className: "tnp-marker",
|
|
html: "<div class=\"tnp-marker-inner\">ТНП</div>",
|
|
iconSize: [40, 40],
|
|
iconAnchor: [20, 20],
|
|
});
|
|
|
|
const marker = L.marker([tnpLat, tnpLon], { icon: tnpIcon }).addTo(mapInstanceRef.current);
|
|
marker.bindPopup("<b>Точка начала поиска</b><br>" + tnpLat.toFixed(6) + ", " + tnpLon.toFixed(6));
|
|
tnpMarkerRef.current = marker;
|
|
|
|
// Центрируем карту
|
|
mapInstanceRef.current.setView([tnpLat, tnpLon], 13);
|
|
}, [tnpLat, tnpLon]);
|
|
|
|
// Обновление стрелки направления
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current || !tnpLat || !tnpLon || !direction || direction === "unknown") {
|
|
if (directionArrowRef.current) {
|
|
mapInstanceRef.current.removeLayer(directionArrowRef.current);
|
|
directionArrowRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Удаляем старую стрелку
|
|
if (directionArrowRef.current) {
|
|
mapInstanceRef.current.removeLayer(directionArrowRef.current);
|
|
}
|
|
|
|
const angle = DIRECTION_ANGLES[direction];
|
|
if (angle === undefined) return;
|
|
|
|
// Создаем стрелку (линия 1км в направлении)
|
|
const distance = 1000; // метров
|
|
const endPoint = calculateDestination(tnpLat, tnpLon, angle, distance);
|
|
|
|
const arrow = L.polyline(
|
|
[[tnpLat, tnpLon], endPoint],
|
|
{
|
|
color: "#ff4d00",
|
|
weight: 3,
|
|
opacity: 0.8,
|
|
dashArray: "10, 5",
|
|
}
|
|
).addTo(mapInstanceRef.current);
|
|
|
|
// Добавляем стрелку на конце
|
|
const arrowHead = L.marker(endPoint, {
|
|
icon: L.divIcon({
|
|
className: "direction-arrow",
|
|
html: "<div style=\"transform: rotate(" + angle + "deg)\">➤</div>",
|
|
iconSize: [30, 30],
|
|
iconAnchor: [15, 15],
|
|
}),
|
|
}).addTo(mapInstanceRef.current);
|
|
|
|
directionArrowRef.current = L.layerGroup([arrow, arrowHead]).addTo(mapInstanceRef.current);
|
|
}, [tnpLat, tnpLon, direction]);
|
|
|
|
// Обновление зон (секторные полигоны)
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current || !tnpLat || !tnpLon || zones.length === 0) {
|
|
if (zonesLayerRef.current) {
|
|
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
|
|
zonesLayerRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Удаляем старые зоны
|
|
if (zonesLayerRef.current) {
|
|
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
|
|
}
|
|
|
|
const zonePolygons = [];
|
|
|
|
zones.forEach((zone, index) => {
|
|
const color = ZONE_COLORS[index] || "#cccccc";
|
|
const directionAngle = DIRECTION_ANGLES[zone.direction] || 0;
|
|
|
|
// Создаем секторный полигон (угол ±45° от направления)
|
|
const sectorAngle = 90; // ширина сектора
|
|
const startAngle = directionAngle - sectorAngle / 2;
|
|
const endAngle = directionAngle + sectorAngle / 2;
|
|
const radiusMeters = zone.distance_km * 1000;
|
|
|
|
const sectorPoints = [[tnpLat, tnpLon]]; // Начало в ТНП
|
|
|
|
// Генерируем точки по дуге
|
|
for (let angle = startAngle; angle <= endAngle; angle += 5) {
|
|
const point = calculateDestination(tnpLat, tnpLon, angle, radiusMeters);
|
|
sectorPoints.push(point);
|
|
}
|
|
|
|
sectorPoints.push([tnpLat, tnpLon]); // Замыкаем полигон
|
|
|
|
const polygon = L.polygon(sectorPoints, {
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: 0.2,
|
|
weight: 2,
|
|
opacity: 0.6,
|
|
});
|
|
|
|
polygon.bindPopup(
|
|
"<b>" + zone.name + "</b><br>" +
|
|
"Приоритет: " + zone.priority + "<br>" +
|
|
"Оценка: " + zone.score + "<br>" +
|
|
"Расстояние: " + zone.distance_km.toFixed(2) + " км<br>" +
|
|
"<small>" + zone.reasoning + "</small>"
|
|
);
|
|
|
|
zonePolygons.push(polygon);
|
|
});
|
|
|
|
zonesLayerRef.current = L.layerGroup(zonePolygons).addTo(mapInstanceRef.current);
|
|
}, [tnpLat, tnpLon, zones]);
|
|
|
|
// Обновление тепловой карты
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current) return;
|
|
|
|
// Удаляем старый слой
|
|
if (heatmapLayerRef.current) {
|
|
mapInstanceRef.current.removeLayer(heatmapLayerRef.current);
|
|
heatmapLayerRef.current = null;
|
|
}
|
|
|
|
if (!heatmapVisible || heatmapData.length === 0) return;
|
|
|
|
// Создаем heatmap слой
|
|
const heatPoints = heatmapData.map(point => [
|
|
point.lat,
|
|
point.lon,
|
|
point.intensity || 1.0
|
|
]);
|
|
|
|
const heatLayer = L.heatLayer(heatPoints, {
|
|
radius: 25,
|
|
blur: 15,
|
|
maxZoom: 17,
|
|
max: 1.0,
|
|
gradient: {
|
|
0.0: "#0d1117",
|
|
0.3: "#238636",
|
|
0.5: "#ffd166",
|
|
0.7: "#ff9f1a",
|
|
1.0: "#ff4d00"
|
|
}
|
|
}).addTo(mapInstanceRef.current);
|
|
|
|
heatmapLayerRef.current = heatLayer;
|
|
}, [heatmapVisible, heatmapData]);
|
|
|
|
// Вычисление точки назначения (lat, lon, angle, distance)
|
|
const calculateDestination = (lat, lon, bearing, distance) => {
|
|
const R = 6371000; // Радиус Земли в метрах
|
|
const φ1 = (lat * Math.PI) / 180;
|
|
const λ1 = (lon * Math.PI) / 180;
|
|
const θ = (bearing * Math.PI) / 180;
|
|
|
|
const φ2 = Math.asin(
|
|
Math.sin(φ1) * Math.cos(distance / R) +
|
|
Math.cos(φ1) * Math.sin(distance / R) * Math.cos(θ)
|
|
);
|
|
|
|
const λ2 = λ1 + Math.atan2(
|
|
Math.sin(θ) * Math.sin(distance / R) * Math.cos(φ1),
|
|
Math.cos(distance / R) - Math.sin(φ1) * Math.sin(φ2)
|
|
);
|
|
|
|
return [(φ2 * 180) / Math.PI, (λ2 * 180) / Math.PI];
|
|
};
|
|
|
|
return (
|
|
<div className="map-view-container">
|
|
<div className="map-controls">
|
|
<button
|
|
className={heatmapVisible ? "control-btn active" : "control-btn"}
|
|
onClick={() => setHeatmapVisible(!heatmapVisible)}
|
|
disabled={heatmapData.length === 0}
|
|
>
|
|
{heatmapVisible ? "🔥 Скрыть тепловую карту" : "🔥 Показать тепловую карту"}
|
|
</button>
|
|
</div>
|
|
<div ref={mapRef} className="map-view" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MapView;
|