import React, { useState, useEffect } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'; import { Icon } from 'leaflet'; import L from 'leaflet'; // For divIcon and other Leaflet utilities import { useSocket } from '../contexts/SocketContext'; import api from '../services/api'; import { format } from 'date-fns'; import { ServerIcon, ExclamationTriangleIcon, SignalIcon, EyeIcon } from '@heroicons/react/24/outline'; // Fix for default markers in React Leaflet import 'leaflet/dist/leaflet.css'; import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; import iconUrl from 'leaflet/dist/images/marker-icon.png'; import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; delete Icon.Default.prototype._getIconUrl; Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl, }); // Component to handle dynamic map bounds const FitBounds = ({ bounds }) => { const map = useMap(); useEffect(() => { if (bounds && bounds.length === 2) { map.fitBounds(bounds, { padding: [20, 20] }); } }, [bounds, map]); return null; }; // Custom icons const createDeviceIcon = (status, hasDetections) => { let color = '#6b7280'; // gray for offline/inactive if (status === 'online') { color = hasDetections ? '#ef4444' : '#22c55e'; // red if detecting, green if online } return new Icon({ iconUrl: `data:image/svg+xml;base64,${btoa(` `)}`, iconSize: [32, 32], iconAnchor: [16, 16], popupAnchor: [0, -16], }); }; // Drone detection icon const createDroneIcon = (rssi, droneType) => { // Color based on signal strength let color = '#ff6b6b'; // Default red if (rssi > -50) color = '#ff4757'; // Very strong - bright red else if (rssi > -60) color = '#ff6b6b'; // Strong - red else if (rssi > -70) color = '#ffa726'; // Medium - orange else if (rssi > -80) color = '#ffca28'; // Weak - yellow else color = '#66bb6a'; // Very weak - green return new Icon({ iconUrl: `data:image/svg+xml;base64,${btoa(` `)}`, iconSize: [32, 32], iconAnchor: [16, 16], popupAnchor: [0, -16], }); }; const MapView = () => { const [devices, setDevices] = useState([]); const [selectedDevice, setSelectedDevice] = useState(null); const [loading, setLoading] = useState(true); const [mapCenter, setMapCenter] = useState([59.3293, 18.0686]); // Default to Stockholm center const [mapZoom, setMapZoom] = useState(10); // Default zoom level const [mapBounds, setMapBounds] = useState(null); const [showDroneDetections, setShowDroneDetections] = useState(true); const [droneDetectionHistory, setDroneDetectionHistory] = useState([]); const { recentDetections, deviceStatus } = useSocket(); // Drone types mapping const droneTypes = { 1: "DJI Mavic", 2: "Racing Drone", 3: "DJI Phantom", 4: "Fixed Wing", 5: "Surveillance", 0: "Unknown" }; useEffect(() => { fetchDevices(); const interval = setInterval(fetchDevices, 30000); // Refresh every 30 seconds return () => clearInterval(interval); }, []); // Update drone detection history when new detections arrive useEffect(() => { if (recentDetections.length > 0) { const latestDetection = recentDetections[0]; console.log('MapView: Processing new detection:', latestDetection); // Add to history with timestamp for fade-out const newDetection = { ...latestDetection, timestamp: Date.now(), id: `${latestDetection.device_id}-${latestDetection.drone_id || 'unknown'}-${latestDetection.device_timestamp || Date.now()}` }; console.log('MapView: Adding to history:', newDetection); setDroneDetectionHistory(prev => { const newHistory = [newDetection, ...prev.slice(0, 19)]; console.log('MapView: Detection history updated, length:', newHistory.length); return newHistory; }); } }, [recentDetections]); // Clean up old detections (fade them out after 5 minutes) useEffect(() => { const cleanup = setInterval(() => { const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); setDroneDetectionHistory(prev => prev.filter(detection => detection.timestamp > fiveMinutesAgo) ); }, 30000); // Clean up every 30 seconds return () => clearInterval(cleanup); }, []); const fetchDevices = async () => { try { const response = await api.get('/devices/map'); const deviceData = response.data.data; setDevices(deviceData); // Calculate bounds dynamically based on device locations if (deviceData.length > 0) { const lats = deviceData.map(device => device.geo_lat); const lons = deviceData.map(device => device.geo_lon); const minLat = Math.min(...lats); const maxLat = Math.max(...lats); const minLon = Math.min(...lons); const maxLon = Math.max(...lons); // Add padding around the bounds (10% on each side) const latPadding = (maxLat - minLat) * 0.1; const lonPadding = (maxLon - minLon) * 0.1; const bounds = [ [minLat - latPadding, minLon - lonPadding], // Southwest [maxLat + latPadding, maxLon + lonPadding] // Northeast ]; setMapBounds(bounds); // Set center to the middle of all devices const centerLat = (minLat + maxLat) / 2; const centerLon = (minLon + maxLon) / 2; setMapCenter([centerLat, centerLon]); } } catch (error) { console.error('Error fetching devices:', error); } finally { setLoading(false); } }; const getDeviceStatus = (device) => { const realtimeStatus = deviceStatus[device.id]; if (realtimeStatus) { return realtimeStatus.status; } return device.status || 'offline'; }; const getDeviceDetections = (deviceId) => { return recentDetections.filter(d => d.device_id === deviceId); }; const getDetectionAge = (detection) => { const ageMs = Date.now() - detection.timestamp; const ageMinutes = ageMs / (1000 * 60); return ageMinutes; }; const getDetectionOpacity = (detection) => { const age = getDetectionAge(detection); if (age < 1) return 1.0; // Full opacity for first minute if (age < 3) return 0.8; // 80% for 1-3 minutes if (age < 5) return 0.5; // 50% for 3-5 minutes return 0.2; // 20% for older detections }; if (loading) { return (
); } return (

Device Map

Real-time view of all devices and drone detections

{droneDetectionHistory.length > 0 && (
{droneDetectionHistory.length} recent detection{droneDetectionHistory.length > 1 ? 's' : ''}
)}
{/* Map */}
{devices .filter(device => device.geo_lat && device.geo_lon) .map(device => { const status = getDeviceStatus(device); const detections = getDeviceDetections(device.id); const hasRecentDetections = detections.length > 0; return ( setSelectedDevice(device), }} > ); })} {/* RSSI-based Detection Rings around Detectors */} {showDroneDetections && (() => { const filteredDetections = droneDetectionHistory .filter(detection => { const hasCoords = detection.geo_lat && detection.geo_lon; console.log('MapView: Filtering detection:', detection, 'hasCoords:', hasCoords); return hasCoords; }); // Group detections by detector position to handle multiple drones at same detector const detectionsByDetector = filteredDetections.reduce((acc, detection) => { const key = `${detection.geo_lat}_${detection.geo_lon}`; if (!acc[key]) acc[key] = []; acc[key].push(detection); return acc; }, {}); return Object.entries(detectionsByDetector).flatMap(([detectorKey, detections]) => { return detections.map((detection, droneIndex) => { console.log('MapView: Rendering ring for detection:', detection, 'droneIndex:', droneIndex, 'totalDrones:', detections.length); const opacity = getDetectionOpacity(detection); const age = getDetectionAge(detection); console.log('MapView: Detection age:', age, 'opacity:', opacity); // Calculate ring radius based on RSSI (rough distance estimation) const getRssiRadius = (rssi) => { if (rssi > -40) return 100; // <100m - very close if (rssi > -60) return 500; // ~500m - close if (rssi > -70) return 1500; // ~1.5km - medium if (rssi > -80) return 4000; // ~4km - far if (rssi > -90) return 8000; // ~8km - very far return 15000; // ~15km - maximum range }; const radius = getRssiRadius(detection.rssi); console.log('MapView: Ring radius:', radius, 'for RSSI:', detection.rssi); // Color based on threat level and RSSI strength const getRingColor = (rssi, droneType) => { // Orlan drones (type 1) always red if (droneType === 1) return '#dc2626'; // red-600 // Other drones based on RSSI if (rssi > -60) return '#dc2626'; // red-600 - close if (rssi > -70) return '#ea580c'; // orange-600 - medium return '#16a34a'; // green-600 - far }; const ringColor = getRingColor(detection.rssi, detection.drone_type); // Different visual styles for multiple drones at same detector const getDashPattern = (droneType, droneIndex, totalDrones) => { if (droneType === 1) return null; // Orlan always solid if (totalDrones === 1) return '5, 5'; // Single drone - normal dashed // Multiple drones - different dash patterns const patterns = [ '5, 5', // Standard dashed '10, 3, 3, 3', // Long dash, dot, dot '15, 5', // Long dashes '3, 3', // Short dashes '8, 3, 3, 3, 3, 3' // Complex pattern ]; return patterns[droneIndex % patterns.length]; }; // Slight offset for multiple rings to make them more visible const getPositionOffset = (droneIndex, totalDrones) => { if (totalDrones === 1) return [0, 0]; const offsetDistance = 0.0001; // Very small offset in degrees const angle = (droneIndex * 360 / totalDrones) * (Math.PI / 180); return [ Math.cos(angle) * offsetDistance, Math.sin(angle) * offsetDistance ]; }; const totalDrones = detections.length; const dashPattern = getDashPattern(detection.drone_type, droneIndex, totalDrones); const [latOffset, lonOffset] = getPositionOffset(droneIndex, totalDrones); // Ensure coordinates are numbers and properly calculated const baseLat = parseFloat(detection.geo_lat) || 0; const baseLon = parseFloat(detection.geo_lon) || 0; const centerLat = baseLat + latOffset; const centerLon = baseLon + lonOffset; console.log('MapView: Coordinate calculation - baseLat:', baseLat, 'baseLon:', baseLon, 'latOffset:', latOffset, 'lonOffset:', lonOffset, 'centerLat:', centerLat, 'centerLon:', centerLon); // Validate coordinates before rendering if (!isFinite(centerLat) || !isFinite(centerLon) || centerLat < -90 || centerLat > 90 || centerLon < -180 || centerLon > 180) { console.error('MapView: Invalid coordinates detected, skipping ring:', centerLat, centerLon); return null; } return ( {/* Detection Ring around Detector (NOT drone position) */} 1 ? 0.02 * opacity : 0.05 * opacity, // Less fill for multiple weight: detection.drone_type === 1 ? 3 : (totalDrones > 1 ? 3 : 2), // Thicker for multiple or Orlan opacity: opacity * 0.8, dashArray: dashPattern }} eventHandlers={{ click: () => { console.log('MapView: Ring clicked for drone:', detection); } }} > {/* Drone ID label for multiple drones */} {totalDrones > 1 && age < 5 && ( // Show labels for recent detections with multiple drones ${detection.drone_id || `D${droneIndex + 1}`}
`, className: 'drone-id-label', iconSize: [30, 16], iconAnchor: [15, 8] })} opacity={opacity * 0.9} > )} {/* Optional: Small info marker at detector showing detection details (for single drone) */} {totalDrones === 1 && age < 1 && ( // Only show for very recent detections (< 1 minute) and single drone )} ); }); }); })()} {/* Map Legend - Fixed positioning and visibility */}
Map Legend
Device Online
Device Detecting
Device Offline
{showDroneDetections && ( <>
Drone Detection Rings:
Rings show estimated detection range based on RSSI
Orlan/Military (Always Critical)
Close Range (>-60dBm)
Medium Range (-60 to -70dBm)
Far Range (<-70dBm)
Multiple Drones:
• Different dash patterns
• Drone ID labels shown
• Slight position offsets
Ring size = estimated distance from detector
)}
{/* Device List */}

Device Status

{devices.map(device => { const status = getDeviceStatus(device); const detections = getDeviceDetections(device.id); return ( setSelectedDevice(device)} /> ); })} {devices.length === 0 && (
No devices found
)}
); }; const DevicePopup = ({ device, status, detections }) => (

{device.name || `Device ${device.id}`}

{status}
{device.location_description && (

{device.location_description}

)}
ID: {device.id}
Coordinates: {device.geo_lat}, {device.geo_lon}
{device.last_heartbeat && (
Last seen: {format(new Date(device.last_heartbeat), 'MMM dd, HH:mm')}
)}
{detections.length > 0 && (
{detections.length} recent detection{detections.length > 1 ? 's' : ''}
{detections.slice(0, 3).map((detection, index) => (
Drone {detection.drone_id} • {detection.freq}MHz • {detection.rssi}dBm
))}
)}
); const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory }) => { // Get all detections for this specific drone from history const droneHistory = droneDetectionHistory.filter(d => d.drone_id === detection.drone_id && d.device_id === detection.device_id ).slice(0, 10); // Last 10 detections for this drone // Calculate movement trend from history const calculateMovementTrend = () => { if (droneHistory.length < 2) return null; const sortedHistory = [...droneHistory].sort((a, b) => new Date(a.device_timestamp) - new Date(b.device_timestamp) ); const first = sortedHistory[0]; const latest = sortedHistory[sortedHistory.length - 1]; const rssiChange = latest.rssi - first.rssi; return { change: rssiChange, trend: rssiChange > 2 ? 'APPROACHING' : rssiChange < -2 ? 'RETREATING' : 'STABLE', duration: (new Date(latest.device_timestamp) - new Date(first.device_timestamp)) / 1000 / 60, // minutes detectionCount: droneHistory.length }; }; const movementTrend = calculateMovementTrend(); const firstDetection = droneHistory.length > 0 ? droneHistory.reduce((earliest, current) => new Date(current.device_timestamp) < new Date(earliest.device_timestamp) ? current : earliest ) : detection; return (

🚨 Drone Detection Details

{age < 1 ? 'LIVE' : `${Math.round(age)}m ago`}
{/* Basic Information */}
Drone ID:
{detection.drone_id}
Type:
{droneTypes[detection.drone_type] || 'Unknown'}
RSSI:
-50 ? 'text-red-600' : detection.rssi > -70 ? 'text-orange-600' : 'text-green-600' }`}> {detection.rssi}dBm
Frequency:
{detection.freq}MHz
{/* Detection Timeline */}
Detection Timeline:
First detected: {format(new Date(firstDetection.device_timestamp), 'MMM dd, HH:mm:ss')}
Latest detection: {format(new Date(detection.device_timestamp), 'MMM dd, HH:mm:ss')}
{droneHistory.length > 1 && (
Total detections: {droneHistory.length}
)}
{/* Movement Analysis */} {movementTrend && (
Movement Analysis:
{movementTrend.trend === 'APPROACHING' ? '⚠️ APPROACHING' : movementTrend.trend === 'RETREATING' ? '✅ RETREATING' : '➡️ STABLE POSITION'}
RSSI change: {movementTrend.change > 0 ? '+' : ''}{movementTrend.change.toFixed(1)}dB over {movementTrend.duration.toFixed(1)} minutes
{/* Signal Strength History Graph (simplified) */}
Signal Strength Trend:
{droneHistory.slice(0, 8).reverse().map((hist, idx) => { const height = Math.max(10, Math.min(32, (hist.rssi + 100) / 2)); // Scale -100 to 0 dBm to 10-32px return (
-50 ? 'bg-red-400' : hist.rssi > -70 ? 'bg-orange-400' : 'bg-green-400' }`} style={{ height: `${height}px` }} title={`${hist.rssi}dBm at ${format(new Date(hist.device_timestamp), 'HH:mm:ss')}`} /> ); })}
Last 8 detections (oldest to newest)
)} {/* Current Detection Details */}
Current Detection:
Confidence:
{(detection.confidence_level * 100).toFixed(0)}%
Duration:
{(detection.signal_duration / 1000).toFixed(1)}s
Detector:
Device {detection.device_id}
Location:
{detection.geo_lat?.toFixed(4)}, {detection.geo_lon?.toFixed(4)}
{/* Legacy movement analysis from detection */} {detection.movement_analysis && (
Real-time Analysis:
= 3 ? 'bg-red-100 text-red-800' : detection.movement_analysis.alertLevel >= 2 ? 'bg-orange-100 text-orange-800' : detection.movement_analysis.alertLevel >= 1 ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800' }`}> {detection.movement_analysis.description}
{detection.movement_analysis.rssiTrend && (
Instant trend: {detection.movement_analysis.rssiTrend.trend} {detection.movement_analysis.rssiTrend.change !== 0 && ( ({detection.movement_analysis.rssiTrend.change > 0 ? '+' : ''}{detection.movement_analysis.rssiTrend.change.toFixed(1)}dB) )}
)}
)}
); }; const DeviceListItem = ({ device, status, detections, onClick }) => (
0 ? 'bg-red-400 animate-pulse' : 'bg-green-400' : 'bg-gray-400' }`} />
{device.name || `Device ${device.id}`}
{device.location_description || `${device.geo_lat}, ${device.geo_lon}`}
{detections.length > 0 && (
{detections.length}
)} {status}
); export default MapView;