1075 lines
46 KiB
JavaScript
1075 lines
46 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 { formatFrequency } from '../utils/formatFrequency';
|
||
import { t } from '../utils/tempTranslations';
|
||
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: t('map.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) {
|
||
// Filter devices that have coordinates
|
||
const devicesWithCoords = deviceData.filter(device =>
|
||
device.geo_lat !== null && device.geo_lon !== null
|
||
);
|
||
|
||
if (devicesWithCoords.length > 0) {
|
||
const lats = devicesWithCoords.map(device => device.geo_lat);
|
||
const lons = devicesWithCoords.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 with coordinates');
|
||
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">
|
||
{t('map.title')}
|
||
</h3>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
{t('map.description')}
|
||
</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">{t('map.showDroneDetections')}</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 device to handle multiple drones at same detector
|
||
const detectionsByDetector = filteredDetections.reduce((acc, detection) => {
|
||
const key = detection.device_id; // Group by device_id instead of drone coordinates
|
||
if (!acc[key]) acc[key] = [];
|
||
acc[key].push(detection);
|
||
return acc;
|
||
}, {});
|
||
|
||
return Object.entries(detectionsByDetector).flatMap(([deviceId, detections]) => {
|
||
// Find the detector device for these detections
|
||
// Compare as strings since device IDs are stored as strings
|
||
const detectorDevice = devices.find(d => d.id === deviceId);
|
||
if (!detectorDevice || !detectorDevice.geo_lat || !detectorDevice.geo_lon) {
|
||
console.warn('MapView: No device found or missing coordinates for device_id:', deviceId);
|
||
return [];
|
||
}
|
||
|
||
return detections.map((detection, droneIndex) => {
|
||
console.log('MapView: Rendering ring for detection:', detection, 'droneIndex:', droneIndex, 'totalDrones:', detections.length, 'detector device:', detectorDevice);
|
||
|
||
// Calculate values first
|
||
const totalDrones = detections.length;
|
||
|
||
// Define helper functions first before using them
|
||
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 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 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];
|
||
};
|
||
|
||
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
|
||
];
|
||
};
|
||
|
||
// Now calculate values using the functions
|
||
const opacity = getDetectionOpacity(detection);
|
||
const age = getDetectionAge(detection);
|
||
console.log('MapView: Detection age:', age, 'opacity:', opacity);
|
||
|
||
const radius = getRssiRadius(detection.rssi);
|
||
console.log('MapView: Ring radius:', radius, 'for RSSI:', detection.rssi);
|
||
|
||
const ringColor = getRingColor(detection.rssi, detection.drone_type, droneIndex, totalDrones);
|
||
const dashPattern = getDashPattern(detection.drone_type, droneIndex, totalDrones);
|
||
const [latOffset, lonOffset] = getPositionOffset(droneIndex, totalDrones);
|
||
|
||
// Use detector device coordinates (NOT drone coordinates)
|
||
const baseLat = parseFloat(detectorDevice.geo_lat) || 0;
|
||
const baseLon = parseFloat(detectorDevice.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">{t('map.mapLegend')}</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">{t('map.deviceOnline')}</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">{t('map.deviceDetecting')}</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">{t('map.deviceOffline')}</span>
|
||
</div>
|
||
{showDroneDetections && (
|
||
<>
|
||
<div className="border-t border-gray-200 mt-2 pt-2">
|
||
<div className="font-medium text-gray-800 mb-1">{t('map.droneDetectionRings')}:</div>
|
||
<div className="text-xs text-gray-600 mb-2">{t('map.ringsDescription')}</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">{t('map.orlanMilitary')}</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">{t('map.closeRange')}</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">{t('map.mediumRange')}</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">{t('map.farRange')}</span>
|
||
</div>
|
||
<div className="border-t border-gray-200 mt-2 pt-2">
|
||
<div className="text-xs text-gray-600 mb-1">{t('map.multipleDrones')}:</div>
|
||
<div className="text-xs text-gray-500 mb-1">{t('map.differentColors')}</div>
|
||
<div className="text-xs text-gray-500 mb-1">{t('map.differentPatterns')}</div>
|
||
<div className="text-xs text-gray-500 mb-1">{t('map.droneLabels')}</div>
|
||
<div className="text-xs text-gray-500 mb-1">{t('map.positionOffsets')}</div>
|
||
</div>
|
||
<div className="text-xs text-gray-500 mt-2">
|
||
{t('map.ringSize')}
|
||
</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">{t('map.deviceStatus')}</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} • {formatFrequency(detection.freq)} • {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>{t('map.droneDetectionDetails')}</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 ? t('map.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">{t('map.droneId')}:</span>
|
||
<div className="text-gray-900 font-mono">{detection.drone_id}</div>
|
||
</div>
|
||
<div>
|
||
<span className="font-medium text-gray-700">{t('map.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">{t('map.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">{t('map.frequency')}:</span>
|
||
<div className="text-gray-900">{formatFrequency(detection.freq)}</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">{t('map.detectionTimeline')}:</span>
|
||
<div className="text-xs space-y-1">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">{t('map.firstDetected')}:</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 t('map.unknown');
|
||
})()}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">{t('map.latestDetection')}:</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 t('map.unknown');
|
||
})()}
|
||
</span>
|
||
</div>
|
||
{droneHistory.length > 1 && (
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600">{t('map.totalDetections')}:</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">{t('map.movementAnalysis')}:</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' ? `⚠️ ${t('map.approaching')}` :
|
||
movementTrend.trend === 'RETREATING' ? `✅ ${t('map.retreating')}` :
|
||
`➡️ ${t('map.stablePosition')}`}
|
||
</div>
|
||
<div className="mt-1">
|
||
{t('map.rssiChange')}: {movementTrend.change > 0 ? '+' : ''}{typeof movementTrend.change === 'number' ? movementTrend.change.toFixed(1) : 'N/A'}dB
|
||
{t('map.over')} {typeof movementTrend.duration === 'number' ? movementTrend.duration.toFixed(1) : 'N/A'} {t('map.minutes')}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Signal Strength History Graph (simplified) */}
|
||
<div className="bg-gray-50 rounded p-2">
|
||
<div className="text-gray-600 mb-1">{t('map.signalStrengthTrend')}:</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">{t('map.lastDetections')}</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">{t('map.currentDetection')}:</span>
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
<div>
|
||
<span className="text-gray-600">{t('map.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">{t('map.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">{t('map.detector')}:</span>
|
||
<div className="text-gray-900">{t('map.deviceName')} {detection.device_id}</div>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">{t('map.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">{t('map.realTimeAnalysis')}:</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">{t('map.instantTrend')}:</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 === 'STRENGTHENING' ? t('map.strengthening') :
|
||
detection.movement_analysis.rssiTrend.trend === 'WEAKENING' ? t('map.weakening') :
|
||
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 }) => {
|
||
const hasCoordinates = device.geo_lat !== null && device.geo_lon !== null;
|
||
|
||
return (
|
||
<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">
|
||
{hasCoordinates
|
||
? (device.location_description || `${device.geo_lat}, ${device.geo_lon}`)
|
||
: (
|
||
<span className="text-amber-600 font-medium">
|
||
⚠️ Coordinates missing - please add location
|
||
</span>
|
||
)
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-4">
|
||
{!hasCoordinates && (
|
||
<div className="flex items-center space-x-1 text-amber-600">
|
||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||
<span className="text-xs font-medium">Incomplete</span>
|
||
</div>
|
||
)}
|
||
|
||
{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;
|