Files
drone-detector/client/src/pages/MapView.jsx
2025-09-23 06:14:38 +02:00

1075 lines
46 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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='&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 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;