Files
drone-detector/client/src/pages/MapView.jsx
2025-08-18 05:14:30 +02:00

583 lines
22 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Circle } from 'react-leaflet';
import { Icon } from 'leaflet';
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,
});
// 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 24 24" width="28" height="28">
<circle cx="12" cy="12" r="11" fill="${color}" stroke="#fff" stroke-width="2" opacity="0.8"/>
<path d="M12 6l-2 4h4l-2-4zm0 6l-3 3h6l-3-3z" fill="#fff"/>
</svg>
`)}`,
iconSize: [28, 28],
iconAnchor: [14, 14],
popupAnchor: [0, -14],
});
};
const MapView = () => {
const [devices, setDevices] = useState([]);
const [selectedDevice, setSelectedDevice] = useState(null);
const [loading, setLoading] = useState(true);
const [mapCenter, setMapCenter] = useState([59.4, 18.1]); // Stockholm area center
const [mapZoom, setMapZoom] = useState(11); // Closer zoom for Stockholm area
const [showDroneDetections, setShowDroneDetections] = useState(true);
const [droneDetectionHistory, setDroneDetectionHistory] = useState([]);
const { recentDetections, deviceStatus } = useSocket();
// Drone types mapping
const droneTypes = {
1: "DJI Mavic",
2: "Racing Drone",
3: "DJI Phantom",
4: "Fixed Wing",
5: "Surveillance",
0: "Unknown"
};
useEffect(() => {
fetchDevices();
const interval = setInterval(fetchDevices, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
// Update drone detection history when new detections arrive
useEffect(() => {
if (recentDetections.length > 0) {
const latestDetection = recentDetections[0];
// Add to history with timestamp for fade-out
setDroneDetectionHistory(prev => [
{
...latestDetection,
timestamp: Date.now(),
id: `${latestDetection.device_id}-${latestDetection.drone_id}-${latestDetection.device_timestamp}`
},
...prev.slice(0, 19) // Keep last 20 detections
]);
}
}, [recentDetections]);
// Clean up old detections (fade them out after 5 minutes)
useEffect(() => {
const cleanup = setInterval(() => {
const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);
setDroneDetectionHistory(prev =>
prev.filter(detection => detection.timestamp > fiveMinutesAgo)
);
}, 30000); // Clean up every 30 seconds
return () => clearInterval(cleanup);
}, []);
const fetchDevices = async () => {
try {
const response = await api.get('/devices/map');
const deviceData = response.data.data;
setDevices(deviceData);
// Set map bounds to Stockholm area with all three detectors
if (deviceData.length > 0 && devices.length === 0) {
// Stockholm area bounds that include Arlanda, Naval Base, and Royal Castle
const stockholmBounds = [
[59.2, 17.8], // Southwest
[59.7, 18.4] // Northeast
];
setMapCenter([59.4, 18.1]); // Center of Stockholm area
setMapZoom(11);
}
} 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
center={mapCenter}
zoom={mapZoom}
className="h-full w-full"
whenCreated={(map) => {
// Set bounds to Stockholm area to show all three detectors
const stockholmBounds = [
[59.2, 17.8], // Southwest
[59.7, 18.4] // Northeast
];
map.fitBounds(stockholmBounds, { padding: [20, 20] });
}}
>
<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>
);
})}
{/* Drone Detection Markers */}
{showDroneDetections && droneDetectionHistory
.filter(detection => detection.geo_lat && detection.geo_lon)
.map(detection => {
const opacity = getDetectionOpacity(detection);
const age = getDetectionAge(detection);
return (
<React.Fragment key={detection.id}>
<Marker
position={[detection.geo_lat, detection.geo_lon]}
icon={createDroneIcon(detection.rssi, detection.drone_type)}
opacity={opacity}
>
<Popup>
<DroneDetectionPopup detection={detection} age={age} droneTypes={droneTypes} />
</Popup>
</Marker>
{/* Detection range circle for recent detections */}
{age < 2 && (
<Circle
center={[detection.geo_lat, detection.geo_lon]}
radius={200} // 200m detection radius
pathOptions={{
color: detection.rssi > -60 ? '#ff4757' : '#ffa726',
fillColor: detection.rssi > -60 ? '#ff4757' : '#ffa726',
fillOpacity: 0.1 * opacity,
weight: 2,
opacity: opacity * 0.5
}}
/>
)}
</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 Detections:</div>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full border border-red-600 opacity-100"></div>
<span className="text-gray-700">Strong Signal (&gt;-60dBm)</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-orange-500 rounded-full border border-orange-600 opacity-90"></div>
<span className="text-gray-700">Medium Signal (-60 to -70dBm)</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full border border-green-600 opacity-80"></div>
<span className="text-gray-700">Weak Signal (&lt;-70dBm)</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 border-2 border-red-400 rounded-full bg-transparent"></div>
<span className="text-gray-700">Detection Range</span>
</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: {format(new Date(device.last_heartbeat), 'MMM dd, HH:mm')}
</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 }) => (
<div className="p-2 min-w-[250px]">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-red-700 flex items-center space-x-1">
<span>🚨</span>
<span>Drone Detection</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-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="font-medium text-gray-700">Drone ID:</span>
<div className="text-gray-900">{detection.drone_id}</div>
</div>
<div>
<span className="font-medium text-gray-700">Type:</span>
<div className="text-gray-900">{droneTypes[detection.drone_type] || 'Unknown'}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-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 className="grid grid-cols-2 gap-2">
<div>
<span className="font-medium text-gray-700">Confidence:</span>
<div className="text-gray-900">{(detection.confidence_level * 100).toFixed(0)}%</div>
</div>
<div>
<span className="font-medium text-gray-700">Duration:</span>
<div className="text-gray-900">{(detection.signal_duration / 1000).toFixed(1)}s</div>
</div>
</div>
{/* Movement Analysis */}
{detection.movement_analysis && (
<div className="pt-2 border-t border-gray-200">
<span className="font-medium text-gray-700 block mb-1">Movement 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">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 ? '+' : ''}{detection.movement_analysis.rssiTrend.change.toFixed(1)}dB)
</span>
)}
</span>
</div>
)}
{detection.movement_analysis.proximityLevel && (
<div className="flex items-center space-x-2">
<span className="text-gray-600">Proximity:</span>
<span className={`px-1 py-0.5 rounded text-xs font-medium ${
detection.movement_analysis.proximityLevel === 'VERY_CLOSE' ? 'bg-red-100 text-red-700' :
detection.movement_analysis.proximityLevel === 'CLOSE' ? 'bg-orange-100 text-orange-700' :
detection.movement_analysis.proximityLevel === 'MEDIUM' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{detection.movement_analysis.proximityLevel.replace('_', ' ')}
</span>
</div>
)}
</div>
</div>
)}
<div className="pt-2 border-t border-gray-200">
<div className="text-xs text-gray-500">
<div>Detected by: Device {detection.device_id}</div>
<div>Location: {detection.geo_lat?.toFixed(4)}, {detection.geo_lon?.toFixed(4)}</div>
<div>Time: {format(new Date(detection.device_timestamp), 'MMM dd, HH:mm:ss')}</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;