1038 lines
44 KiB
JavaScript
1038 lines
44 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
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 initial map bounds - ONLY runs once on first load
|
|
const FitBounds = ({ bounds, shouldFit }) => {
|
|
const map = useMap();
|
|
const [hasRun, setHasRun] = useState(false);
|
|
|
|
useEffect(() => {
|
|
console.log('FitBounds: useEffect triggered - bounds:', bounds, 'shouldFit:', shouldFit, 'hasRun:', hasRun);
|
|
if (bounds && bounds.length === 2 && shouldFit && !hasRun) {
|
|
console.log('FitBounds: Calling map.fitBounds ONCE with bounds:', bounds);
|
|
map.fitBounds(bounds, { padding: [20, 20] });
|
|
setHasRun(true); // Ensure this only runs once ever
|
|
}
|
|
}, [bounds, map, shouldFit, hasRun]);
|
|
|
|
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(`
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="32" height="32">
|
|
<circle cx="12" cy="12" r="10" fill="${color}" stroke="#fff" stroke-width="2"/>
|
|
<path d="M12 8v4l3 3" stroke="#fff" stroke-width="2" fill="none"/>
|
|
</svg>
|
|
`)}`,
|
|
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(`
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
<!-- Drone body (center) -->
|
|
<ellipse cx="16" cy="16" rx="6" ry="3" fill="${color}" stroke="#fff" stroke-width="1"/>
|
|
|
|
<!-- Drone arms -->
|
|
<line x1="6" y1="10" x2="26" y2="22" stroke="${color}" stroke-width="2"/>
|
|
<line x1="26" y1="10" x2="6" y2="22" stroke="${color}" stroke-width="2"/>
|
|
|
|
<!-- Propellers (rotors) -->
|
|
<circle cx="6" cy="10" r="3" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.9"/>
|
|
<circle cx="26" cy="10" r="3" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.9"/>
|
|
<circle cx="6" cy="22" r="3" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.9"/>
|
|
<circle cx="26" cy="22" r="3" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.9"/>
|
|
|
|
<!-- Propeller blades -->
|
|
<ellipse cx="6" cy="10" rx="2.5" ry="0.8" fill="#fff" opacity="0.7"/>
|
|
<ellipse cx="26" cy="10" rx="2.5" ry="0.8" fill="#fff" opacity="0.7"/>
|
|
<ellipse cx="6" cy="22" rx="2.5" ry="0.8" fill="#fff" opacity="0.7"/>
|
|
<ellipse cx="26" cy="22" rx="2.5" ry="0.8" fill="#fff" opacity="0.7"/>
|
|
|
|
<!-- Camera/gimbal -->
|
|
<circle cx="16" cy="19" r="1.5" fill="#333" stroke="#fff" stroke-width="0.5"/>
|
|
</svg>
|
|
`)}`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
popupAnchor: [0, -16],
|
|
});
|
|
};
|
|
|
|
const MapView = () => {
|
|
console.log('MapView: Component render started');
|
|
|
|
const location = useLocation();
|
|
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 [shouldFitBounds, setShouldFitBounds] = useState(false);
|
|
const [isInitialLoad, setIsInitialLoad] = useState(true); // Track if this is the first load
|
|
const [showDroneDetections, setShowDroneDetections] = useState(true);
|
|
const [droneDetectionHistory, setDroneDetectionHistory] = useState([]);
|
|
const [droneTypes, setDroneTypes] = useState({});
|
|
const { recentDetections, deviceStatus } = useSocket();
|
|
|
|
// Fetch drone types from API
|
|
const fetchDroneTypes = async () => {
|
|
try {
|
|
const response = await api.get('/drone-types');
|
|
if (response.data.success) {
|
|
// Convert array to object for easy lookup
|
|
const typesMap = {};
|
|
response.data.data.forEach(type => {
|
|
typesMap[type.id] = type.name;
|
|
});
|
|
setDroneTypes(typesMap);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching drone types:', error);
|
|
// Fallback to basic mapping
|
|
setDroneTypes({
|
|
0: "Russian Orlan",
|
|
1: "Bayraktar TB2",
|
|
2: "MQ-9 Reaper",
|
|
3: "Iranian Shahed",
|
|
10: "DJI Mavic",
|
|
11: "DJI Phantom",
|
|
20: "DJI Mini",
|
|
99: "Unknown"
|
|
});
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchDroneTypes();
|
|
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: `drone-${latestDetection.drone_id || 'unknown'}-${latestDetection.device_id}` // Group by drone_id, not timestamp
|
|
};
|
|
|
|
console.log('MapView: Adding to history:', newDetection);
|
|
|
|
setDroneDetectionHistory(prev => {
|
|
// Remove any existing detection for this same drone_id to avoid duplicates
|
|
const filtered = prev.filter(d => d.drone_id !== latestDetection.drone_id);
|
|
const newHistory = [newDetection, ...filtered.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);
|
|
}, []);
|
|
|
|
// Handle device focusing when navigated from Devices page
|
|
useEffect(() => {
|
|
const focusDevice = location.state?.focusDevice;
|
|
if (focusDevice && focusDevice.lat && focusDevice.lon) {
|
|
console.log('MapView: Focusing on device:', focusDevice);
|
|
|
|
// Set map center and zoom to the device location
|
|
setMapCenter([focusDevice.lat, focusDevice.lon]);
|
|
setMapZoom(16); // Close zoom for individual device
|
|
setShouldFitBounds(false); // Don't fit all devices, focus on this one
|
|
|
|
// Find and select the device if it exists in the devices list
|
|
// This will happen after devices are loaded
|
|
if (devices.length > 0) {
|
|
const device = devices.find(d => d.id === focusDevice.id);
|
|
if (device) {
|
|
setSelectedDevice(device);
|
|
}
|
|
}
|
|
}
|
|
}, [location.state, devices]);
|
|
|
|
const fetchDevices = async () => {
|
|
try {
|
|
// Debug: Check if token exists before making request
|
|
const token = localStorage.getItem('token');
|
|
console.log('MapView: fetchDevices - Token exists:', !!token, 'Token length:', token?.length);
|
|
console.log('MapView: fetchDevices - Current state before fetch - isInitialLoad:', isInitialLoad, 'shouldFitBounds:', shouldFitBounds);
|
|
|
|
const response = await api.get('/devices/map');
|
|
const deviceData = response.data.data;
|
|
|
|
console.log('MapView: fetchDevices - Device data received, length:', deviceData.length);
|
|
setDevices(deviceData);
|
|
|
|
// Set initial bounds and center ONLY on first load
|
|
if (deviceData.length > 0 && isInitialLoad) {
|
|
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 (15% on each side for better visibility)
|
|
const latPadding = (maxLat - minLat) * 0.15;
|
|
const lonPadding = (maxLon - minLon) * 0.15;
|
|
|
|
const bounds = [
|
|
[minLat - latPadding, minLon - lonPadding], // Southwest
|
|
[maxLat + latPadding, maxLon + lonPadding] // Northeast
|
|
];
|
|
|
|
console.log('MapView: Setting initial bounds and center for devices');
|
|
setMapBounds(bounds);
|
|
setShouldFitBounds(true);
|
|
|
|
// Calculate center
|
|
const centerLat = (minLat + maxLat) / 2;
|
|
const centerLon = (minLon + maxLon) / 2;
|
|
setMapCenter([centerLat, centerLon]);
|
|
|
|
// Disable automatic fitting after initial setup (with longer delay)
|
|
setTimeout(() => {
|
|
console.log('MapView: Disabling automatic bounds fitting - manual navigation now preserved');
|
|
setShouldFitBounds(false);
|
|
}, 2000);
|
|
}
|
|
|
|
// Always mark initial load as complete after first fetch
|
|
if (isInitialLoad) {
|
|
console.log('MapView: Setting isInitialLoad to false');
|
|
setIsInitialLoad(false);
|
|
}
|
|
} 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 (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
Device Map
|
|
</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Real-time view of all devices and drone detections
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<label className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={showDroneDetections}
|
|
onChange={(e) => setShowDroneDetections(e.target.checked)}
|
|
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">Show Drone Detections</span>
|
|
</label>
|
|
|
|
{droneDetectionHistory.length > 0 && (
|
|
<div className="text-sm text-gray-500">
|
|
{droneDetectionHistory.length} recent detection{droneDetectionHistory.length > 1 ? 's' : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Map */}
|
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
|
<div className="h-96 lg:h-[600px] relative">
|
|
<MapContainer
|
|
key="drone-map" // Static key to prevent re-creation
|
|
center={[59.3293, 18.0686]} // Static center - Stockholm (will be overridden by FitBounds on first load)
|
|
zoom={mapZoom}
|
|
className="h-full w-full"
|
|
>
|
|
{mapBounds && <FitBounds bounds={mapBounds} shouldFit={shouldFitBounds} />}
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
{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 (
|
|
<Marker
|
|
key={device.id}
|
|
position={[device.geo_lat, device.geo_lon]}
|
|
icon={createDeviceIcon(status, hasRecentDetections)}
|
|
eventHandlers={{
|
|
click: () => setSelectedDevice(device),
|
|
}}
|
|
>
|
|
<Popup>
|
|
<DevicePopup
|
|
device={device}
|
|
status={status}
|
|
detections={detections}
|
|
/>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
|
|
{/* 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 multiple drone differentiation
|
|
const getRingColor = (rssi, droneType, droneIndex, totalDrones) => {
|
|
// Orlan drones (type 1) always red
|
|
if (droneType === 1) return '#dc2626'; // red-600
|
|
|
|
// If multiple drones, use different colors to distinguish them
|
|
if (totalDrones > 1) {
|
|
const colors = [
|
|
'#dc2626', // red-600
|
|
'#ea580c', // orange-600
|
|
'#16a34a', // green-600
|
|
'#7c3aed', // violet-600
|
|
'#0284c7', // sky-600
|
|
'#db2777', // pink-600
|
|
'#059669', // emerald-600
|
|
'#7c2d12' // amber-800
|
|
];
|
|
return colors[droneIndex % colors.length];
|
|
}
|
|
|
|
// Single drone - color based on RSSI strength
|
|
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, droneIndex, totalDrones);
|
|
|
|
// 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 (
|
|
<React.Fragment key={`${detection.id}_${droneIndex}`}>
|
|
{/* Detection Ring around Detector (NOT drone position) */}
|
|
<Circle
|
|
center={[centerLat, centerLon]} // Detector position with slight offset
|
|
radius={radius}
|
|
pathOptions={{
|
|
color: ringColor,
|
|
fillColor: ringColor,
|
|
fillOpacity: totalDrones > 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: (e) => {
|
|
e.originalEvent.preventDefault();
|
|
e.originalEvent.stopPropagation();
|
|
console.log('MapView: Ring clicked for drone:', detection);
|
|
setSelectedDetection(detection);
|
|
}
|
|
}}
|
|
>
|
|
<Popup>
|
|
<DroneDetectionPopup
|
|
detection={detection}
|
|
age={age}
|
|
droneTypes={droneTypes}
|
|
droneDetectionHistory={droneDetectionHistory}
|
|
/>
|
|
</Popup>
|
|
</Circle>
|
|
|
|
{/* Drone ID label for multiple drones */}
|
|
{totalDrones > 1 && age < 5 && ( // Show labels for recent detections with multiple drones
|
|
<Marker
|
|
position={[
|
|
centerLat + (latOffset * 2),
|
|
centerLon + (lonOffset * 2)
|
|
]}
|
|
icon={L.divIcon({
|
|
html: `<div style="
|
|
background: ${ringColor};
|
|
color: white;
|
|
padding: 2px 6px;
|
|
border-radius: 10px;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
white-space: nowrap;
|
|
">
|
|
${detection.drone_id || `D${droneIndex + 1}`}
|
|
</div>`,
|
|
className: 'drone-id-label',
|
|
iconSize: [30, 16],
|
|
iconAnchor: [15, 8]
|
|
})}
|
|
opacity={opacity * 0.9}
|
|
>
|
|
<Popup>
|
|
<DroneDetectionPopup
|
|
detection={detection}
|
|
age={age}
|
|
droneTypes={droneTypes}
|
|
droneDetectionHistory={droneDetectionHistory}
|
|
/>
|
|
</Popup>
|
|
</Marker>
|
|
)}
|
|
|
|
{/* 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
|
|
<Marker
|
|
position={[centerLat, centerLon]}
|
|
icon={createDroneIcon(detection.rssi, detection.drone_type)}
|
|
opacity={opacity * 0.7}
|
|
>
|
|
<Popup>
|
|
<DroneDetectionPopup
|
|
detection={detection}
|
|
age={age}
|
|
droneTypes={droneTypes}
|
|
droneDetectionHistory={droneDetectionHistory}
|
|
/>
|
|
</Popup>
|
|
</Marker>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
});
|
|
});
|
|
})()}
|
|
</MapContainer>
|
|
|
|
{/* Map Legend - Fixed positioning and visibility */}
|
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg p-3 shadow-lg text-xs border border-gray-200 z-[1000] max-w-xs">
|
|
<div className="font-semibold mb-2 text-gray-800">Map Legend</div>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 bg-green-500 rounded-full border border-green-600"></div>
|
|
<span className="text-gray-700">Device Online</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 bg-red-500 rounded-full border border-red-600"></div>
|
|
<span className="text-gray-700">Device Detecting</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 bg-gray-500 rounded-full border border-gray-600"></div>
|
|
<span className="text-gray-700">Device Offline</span>
|
|
</div>
|
|
{showDroneDetections && (
|
|
<>
|
|
<div className="border-t border-gray-200 mt-2 pt-2">
|
|
<div className="font-medium text-gray-800 mb-1">Drone Detection Rings:</div>
|
|
<div className="text-xs text-gray-600 mb-2">Rings show estimated detection range based on RSSI</div>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 border-2 border-red-600 rounded-full bg-red-600 bg-opacity-10"></div>
|
|
<span className="text-gray-700">Orlan/Military (Always Critical)</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 border-2 border-red-500 rounded-full bg-red-500 bg-opacity-10"></div>
|
|
<span className="text-gray-700">Close Range (>-60dBm)</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 border-2 border-orange-500 rounded-full bg-orange-500 bg-opacity-10"></div>
|
|
<span className="text-gray-700">Medium Range (-60 to -70dBm)</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 border-2 border-green-500 rounded-full bg-green-500 bg-opacity-10"></div>
|
|
<span className="text-gray-700">Far Range (<-70dBm)</span>
|
|
</div>
|
|
<div className="border-t border-gray-200 mt-2 pt-2">
|
|
<div className="text-xs text-gray-600 mb-1">Multiple Drones at Same Detector:</div>
|
|
<div className="text-xs text-gray-500 mb-1">• Different colors to distinguish drones</div>
|
|
<div className="text-xs text-gray-500 mb-1">• Different dash patterns</div>
|
|
<div className="text-xs text-gray-500 mb-1">• Drone ID labels shown</div>
|
|
<div className="text-xs text-gray-500 mb-1">• Slight position offsets for visibility</div>
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-2">
|
|
Ring size = estimated distance from detector
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Device List */}
|
|
<div className="bg-white rounded-lg shadow">
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<h3 className="text-lg font-medium text-gray-900">Device Status</h3>
|
|
</div>
|
|
<div className="divide-y divide-gray-200">
|
|
{devices.map(device => {
|
|
const status = getDeviceStatus(device);
|
|
const detections = getDeviceDetections(device.id);
|
|
|
|
return (
|
|
<DeviceListItem
|
|
key={device.id}
|
|
device={device}
|
|
status={status}
|
|
detections={detections}
|
|
onClick={() => setSelectedDevice(device)}
|
|
/>
|
|
);
|
|
})}
|
|
{devices.length === 0 && (
|
|
<div className="px-6 py-8 text-center text-gray-500">
|
|
No devices found
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DevicePopup = ({ device, status, detections }) => (
|
|
<div className="p-2 min-w-[200px]">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="font-semibold text-gray-900">
|
|
{device.name || `Device ${device.id}`}
|
|
</h4>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
status === 'online'
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-red-100 text-red-800'
|
|
}`}>
|
|
{status}
|
|
</span>
|
|
</div>
|
|
|
|
{device.location_description && (
|
|
<p className="text-sm text-gray-600 mb-2">
|
|
{device.location_description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="text-xs text-gray-500 space-y-1">
|
|
<div>ID: {device.id}</div>
|
|
<div>Coordinates: {device.geo_lat}, {device.geo_lon}</div>
|
|
{device.last_heartbeat && (
|
|
<div>
|
|
Last seen: {(() => {
|
|
try {
|
|
if (device.last_heartbeat && !isNaN(new Date(device.last_heartbeat).getTime())) {
|
|
return format(new Date(device.last_heartbeat), 'MMM dd, HH:mm');
|
|
}
|
|
} catch (e) {
|
|
console.warn('Invalid last_heartbeat timestamp:', device.last_heartbeat, e);
|
|
}
|
|
return 'Invalid time';
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{detections.length > 0 && (
|
|
<div className="mt-3 pt-2 border-t border-gray-200">
|
|
<div className="flex items-center space-x-1 text-red-600 text-sm font-medium mb-1">
|
|
<ExclamationTriangleIcon className="h-4 w-4" />
|
|
<span>{detections.length} recent detection{detections.length > 1 ? 's' : ''}</span>
|
|
</div>
|
|
{detections.slice(0, 3).map((detection, index) => (
|
|
<div key={index} className="text-xs text-gray-600">
|
|
Drone {detection.drone_id} • {detection.freq}MHz • {detection.rssi}dBm
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
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 (
|
|
<div className="p-3 min-w-[300px] max-w-[400px]">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="font-semibold text-red-700 flex items-center space-x-1">
|
|
<span>🚨</span>
|
|
<span>Drone Detection Details</span>
|
|
</h4>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
age < 1 ? 'bg-red-100 text-red-800' :
|
|
age < 3 ? 'bg-orange-100 text-orange-800' :
|
|
'bg-gray-100 text-gray-800'
|
|
}`}>
|
|
{age < 1 ? 'LIVE' : `${Math.round(age)}m ago`}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-3 text-sm">
|
|
{/* Basic Information */}
|
|
<div className="bg-gray-50 rounded-lg p-2">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<span className="font-medium text-gray-700">Drone ID:</span>
|
|
<div className="text-gray-900 font-mono">{detection.drone_id}</div>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-700">Type:</span>
|
|
<div className="text-gray-900">
|
|
{droneTypes[detection.drone_type] || `Type ${detection.drone_type}`}
|
|
</div>
|
|
{droneTypes[detection.drone_type] && (
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
ID: {detection.drone_type}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
<div>
|
|
<span className="font-medium text-gray-700">RSSI:</span>
|
|
<div className={`font-mono ${
|
|
detection.rssi > -50 ? 'text-red-600' :
|
|
detection.rssi > -70 ? 'text-orange-600' :
|
|
'text-green-600'
|
|
}`}>
|
|
{detection.rssi}dBm
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-700">Frequency:</span>
|
|
<div className="text-gray-900">{detection.freq}MHz</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detection Timeline */}
|
|
<div className="border-t border-gray-200 pt-2">
|
|
<span className="font-medium text-gray-700 block mb-2">Detection Timeline:</span>
|
|
<div className="text-xs space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">First detected:</span>
|
|
<span className="font-mono text-gray-900">
|
|
{(() => {
|
|
const timestamp = firstDetection.device_timestamp || firstDetection.timestamp || firstDetection.server_timestamp;
|
|
try {
|
|
if (timestamp && !isNaN(new Date(timestamp).getTime())) {
|
|
return format(new Date(timestamp), 'MMM dd, HH:mm:ss');
|
|
}
|
|
} catch (e) {
|
|
console.warn('Invalid firstDetection timestamp:', timestamp, e);
|
|
}
|
|
return 'Unknown';
|
|
})()}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Latest detection:</span>
|
|
<span className="font-mono text-gray-900">
|
|
{(() => {
|
|
const timestamp = detection.device_timestamp || detection.timestamp || detection.server_timestamp;
|
|
try {
|
|
if (timestamp && !isNaN(new Date(timestamp).getTime())) {
|
|
return format(new Date(timestamp), 'MMM dd, HH:mm:ss');
|
|
}
|
|
} catch (e) {
|
|
console.warn('Invalid detection timestamp:', timestamp, e);
|
|
}
|
|
return 'Unknown';
|
|
})()}
|
|
</span>
|
|
</div>
|
|
{droneHistory.length > 1 && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Total detections:</span>
|
|
<span className="font-medium text-gray-900">{droneHistory.length}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Movement Analysis */}
|
|
{movementTrend && (
|
|
<div className="border-t border-gray-200 pt-2">
|
|
<span className="font-medium text-gray-700 block mb-2">Movement Analysis:</span>
|
|
<div className="text-xs space-y-2">
|
|
<div className={`px-2 py-1 rounded ${
|
|
movementTrend.trend === 'APPROACHING' ? 'bg-red-100 text-red-800' :
|
|
movementTrend.trend === 'RETREATING' ? 'bg-green-100 text-green-800' :
|
|
'bg-yellow-100 text-yellow-800'
|
|
}`}>
|
|
<div className="font-medium">
|
|
{movementTrend.trend === 'APPROACHING' ? '⚠️ APPROACHING' :
|
|
movementTrend.trend === 'RETREATING' ? '✅ RETREATING' :
|
|
'➡️ STABLE POSITION'}
|
|
</div>
|
|
<div className="mt-1">
|
|
RSSI change: {movementTrend.change > 0 ? '+' : ''}{typeof movementTrend.change === 'number' ? movementTrend.change.toFixed(1) : 'N/A'}dB
|
|
over {typeof movementTrend.duration === 'number' ? movementTrend.duration.toFixed(1) : 'N/A'} minutes
|
|
</div>
|
|
</div>
|
|
|
|
{/* Signal Strength History Graph (simplified) */}
|
|
<div className="bg-gray-50 rounded p-2">
|
|
<div className="text-gray-600 mb-1">Signal Strength Trend:</div>
|
|
<div className="flex items-end space-x-1 h-8">
|
|
{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 (
|
|
<div
|
|
key={idx}
|
|
className={`w-2 rounded-t ${
|
|
hist.rssi > -50 ? 'bg-red-400' :
|
|
hist.rssi > -70 ? 'bg-orange-400' :
|
|
'bg-green-400'
|
|
}`}
|
|
style={{ height: `${height}px` }}
|
|
title={`${hist.rssi}dBm at ${(() => {
|
|
const timestamp = hist.device_timestamp || hist.timestamp || hist.server_timestamp;
|
|
try {
|
|
if (timestamp && !isNaN(new Date(timestamp).getTime())) {
|
|
return format(new Date(timestamp), 'HH:mm:ss');
|
|
}
|
|
} catch (e) {
|
|
console.warn('Invalid timestamp:', timestamp, e);
|
|
}
|
|
return 'Invalid time';
|
|
})()}`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-1">Last 8 detections (oldest to newest)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Current Detection Details */}
|
|
<div className="border-t border-gray-200 pt-2">
|
|
<span className="font-medium text-gray-700 block mb-2">Current Detection:</span>
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<span className="text-gray-600">Confidence:</span>
|
|
<div className="text-gray-900">
|
|
{typeof detection.confidence_level === 'number' ? (detection.confidence_level * 100).toFixed(0) : 'N/A'}%
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-600">Duration:</span>
|
|
<div className="text-gray-900">
|
|
{typeof detection.signal_duration === 'number' ? (detection.signal_duration / 1000).toFixed(1) : 'N/A'}s
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-600">Detector:</span>
|
|
<div className="text-gray-900">Device {detection.device_id}</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-600">Location:</span>
|
|
<div className="text-gray-900 font-mono">
|
|
{typeof detection.geo_lat === 'number' ? detection.geo_lat.toFixed(4) : 'N/A'}, {typeof detection.geo_lon === 'number' ? detection.geo_lon.toFixed(4) : 'N/A'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Legacy movement analysis from detection */}
|
|
{detection.movement_analysis && (
|
|
<div className="border-t border-gray-200 pt-2">
|
|
<span className="font-medium text-gray-700 block mb-1">Real-time Analysis:</span>
|
|
<div className="text-xs space-y-1">
|
|
<div className={`px-2 py-1 rounded ${
|
|
detection.movement_analysis.alertLevel >= 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}
|
|
</div>
|
|
|
|
{detection.movement_analysis.rssiTrend && (
|
|
<div className="flex items-center space-x-2 mt-1">
|
|
<span className="text-gray-600">Instant trend:</span>
|
|
<span className={`font-medium ${
|
|
detection.movement_analysis.rssiTrend.trend === 'STRENGTHENING' ? 'text-red-600' :
|
|
detection.movement_analysis.rssiTrend.trend === 'WEAKENING' ? 'text-green-600' :
|
|
'text-gray-600'
|
|
}`}>
|
|
{detection.movement_analysis.rssiTrend.trend}
|
|
{detection.movement_analysis.rssiTrend.change !== 0 && (
|
|
<span className="ml-1">
|
|
({detection.movement_analysis.rssiTrend.change > 0 ? '+' : ''}{typeof detection.movement_analysis.rssiTrend.change === 'number' ? detection.movement_analysis.rssiTrend.change.toFixed(1) : 'N/A'}dB)
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DeviceListItem = ({ device, status, detections, onClick }) => (
|
|
<div
|
|
className="px-6 py-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className={`w-3 h-3 rounded-full ${
|
|
status === 'online'
|
|
? detections.length > 0 ? 'bg-red-400 animate-pulse' : 'bg-green-400'
|
|
: 'bg-gray-400'
|
|
}`} />
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{device.name || `Device ${device.id}`}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{device.location_description || `${device.geo_lat}, ${device.geo_lon}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
{detections.length > 0 && (
|
|
<div className="flex items-center space-x-1 text-red-600">
|
|
<ExclamationTriangleIcon className="h-4 w-4" />
|
|
<span className="text-sm font-medium">{detections.length}</span>
|
|
</div>
|
|
)}
|
|
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
status === 'online'
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-red-100 text-red-800'
|
|
}`}>
|
|
{status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export default MapView;
|