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
{showDroneDetections && (
<>
Drone Detection Rings:
Rings show estimated detection range based on RSSI
Orlan/Military (Always Critical)
Medium Range (-60 to -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;