163 lines
6.4 KiB
TypeScript
163 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { MapContainer, TileLayer, Polyline, CircleMarker, Popup, useMap } from 'react-leaflet';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { LatLngBounds, LatLng } from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import { getDetectionModeConfig, DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
|
import { DetectionType } from '@/types';
|
|
|
|
type Detection = {
|
|
id: number;
|
|
type: string;
|
|
class: string;
|
|
confidence: number;
|
|
latitude: number;
|
|
longitude: number;
|
|
frame_number: number;
|
|
};
|
|
|
|
type MapModalProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
detections: Detection[];
|
|
detectionType: DetectionType | string;
|
|
};
|
|
|
|
// Component to auto-fit map bounds to show all markers
|
|
function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
|
const map = useMap();
|
|
|
|
useEffect(() => {
|
|
if (!map || !bounds.isValid()) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
map.invalidateSize();
|
|
map.fitBounds(bounds, {
|
|
padding: [50, 50],
|
|
maxZoom: 16,
|
|
animate: true,
|
|
});
|
|
}, 200);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [bounds, map]);
|
|
|
|
return null;
|
|
}
|
|
|
|
export default function MapModal({ open, onClose, detections, detectionType }: MapModalProps) {
|
|
const validDetections = detections.filter((d) => d.latitude && d.longitude);
|
|
|
|
if (validDetections.length === 0) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-4xl h-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Detection Map</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm font-medium">
|
|
No GPS data available for detections.
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const routeCoordinates: [number, number][] = validDetections.map((d) => [
|
|
d.latitude,
|
|
d.longitude,
|
|
]);
|
|
const bounds = new LatLngBounds(
|
|
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
|
|
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
|
|
);
|
|
routeCoordinates.forEach((coord) => bounds.extend(new LatLng(coord[0], coord[1])));
|
|
const center: [number, number] = [
|
|
(bounds.getNorth() + bounds.getSouth()) / 2,
|
|
(bounds.getEast() + bounds.getWest()) / 2,
|
|
];
|
|
|
|
const modeConfig = getDetectionModeConfig(detectionType);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-6xl h-[85vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
|
|
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
|
<DialogTitle className="text-xl font-bold">{modeConfig.label} Map</DialogTitle>
|
|
<p className="text-xs text-muted-foreground font-medium">
|
|
{validDetections.length} points of interest identified with GPS data
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 w-full relative bg-muted/20">
|
|
<MapContainer center={center} zoom={13} className="h-full w-full" scrollWheelZoom={true}>
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
<Polyline positions={routeCoordinates} color="#3b82f6" weight={4} opacity={0.6} />
|
|
|
|
{validDetections.map((detection, idx) => {
|
|
const type = (detection.type || '').toLowerCase();
|
|
const typeConfig = DETECTION_TYPES[type];
|
|
const color = typeConfig
|
|
? { fill: typeConfig.color, stroke: typeConfig.color }
|
|
: { fill: '#64748b', stroke: '#475569' };
|
|
|
|
return (
|
|
<CircleMarker
|
|
key={`${detection.id}-${idx}`}
|
|
center={[detection.latitude, detection.longitude]}
|
|
radius={7}
|
|
fillColor={color.fill}
|
|
color={color.stroke}
|
|
weight={2}
|
|
opacity={1}
|
|
fillOpacity={0.9}
|
|
>
|
|
<Popup>
|
|
<div className="text-sm min-w-[200px] py-1">
|
|
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none capitalize">
|
|
{(detection.type || '').replace(/_/g, ' ')} <span className="text-muted-foreground font-medium text-sm ml-1">#{detection.id}</span>
|
|
</div>
|
|
|
|
<div className="space-y-2 mb-4">
|
|
<div className="flex justify-between items-baseline gap-4">
|
|
<span className="text-muted-foreground text-xs">Frame</span>
|
|
<span className="font-medium text-right font-mono">{detection.frame_number}</span>
|
|
</div>
|
|
<div className="flex justify-between items-baseline gap-4">
|
|
<span className="text-muted-foreground text-xs">Confidence</span>
|
|
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-2 border-t border-border/50">
|
|
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">Location Details</div>
|
|
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
|
|
<div className="space-y-0.5">
|
|
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
|
|
<span className="font-medium tracking-tighter">{detection.latitude.toFixed(6)}</span>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
|
|
<span className="font-medium tracking-tighter">{detection.longitude.toFixed(6)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
</CircleMarker>
|
|
);
|
|
})}
|
|
<FitBounds bounds={bounds} />
|
|
</MapContainer>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|