Files
drone-detector/client/src/pages/MapView.jsx
2025-08-28 12:29:37 +02:00

1022 lines
43 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='&copy; <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 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 (
<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 (&gt;-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 (&lt;-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:</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</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;