"use client"; import { useEffect, useState, useRef } from "react"; import { Location, LocationResponse } from "@/types/location"; import { getDevice, DEFAULT_DEVICE } from "@/lib/devices"; import L from "leaflet"; import { MapContainer, TileLayer, Marker, Popup, Polyline, LayersControl, useMap, } from "react-leaflet"; interface MapViewProps { selectedDevice: string; timeFilter: number; // in hours, 0 = all isPaused: boolean; filterMode: "quick" | "custom"; startTime: string; // datetime-local format endTime: string; // datetime-local format } interface DeviceInfo { id: string; name: string; color: string; } // Component to auto-center map to latest position and track zoom function SetViewOnChange({ center, zoom, onZoomChange }: { center: [number, number] | null; zoom: number; onZoomChange: (zoom: number) => void; }) { const map = useMap(); useEffect(() => { if (center) { map.setView(center, zoom, { animate: true }); } }, [center, zoom, map]); useEffect(() => { const handleZoom = () => { onZoomChange(map.getZoom()); }; // Initial zoom onZoomChange(map.getZoom()); // Listen to zoom changes map.on('zoomend', handleZoom); return () => { map.off('zoomend', handleZoom); }; }, [map, onZoomChange]); return null; } export default function MapView({ selectedDevice, timeFilter, isPaused, filterMode, startTime, endTime }: MapViewProps) { const [locations, setLocations] = useState([]); const [devices, setDevices] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [mapCenter, setMapCenter] = useState<[number, number] | null>(null); const [currentZoom, setCurrentZoom] = useState(12); const intervalRef = useRef(null); // Add animation styles for latest marker useEffect(() => { // Inject CSS animation for marker pulse effect if (typeof document !== 'undefined') { const styleId = 'marker-animation-styles'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` @keyframes marker-pulse { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.15); opacity: 0.8; } 100% { transform: scale(1); opacity: 1; } } .latest-marker { animation: marker-pulse 2s ease-in-out infinite; } `; document.head.appendChild(style); } } }, []); // Fetch devices from API useEffect(() => { const fetchDevices = async () => { try { const response = await fetch("/api/devices/public"); if (response.ok) { const data = await response.json(); const devicesMap = data.devices.reduce((acc: Record, dev: DeviceInfo) => { acc[dev.id] = dev; return acc; }, {}); setDevices(devicesMap); } } catch (err) { console.error("Failed to fetch devices:", err); // Fallback to hardcoded devices if API fails } }; fetchDevices(); // Refresh devices every 30 seconds (in case of updates) const interval = setInterval(fetchDevices, 30000); return () => clearInterval(interval); }, []); // Fetch locations useEffect(() => { const fetchLocations = async () => { if (isPaused) return; // Skip fetching when paused try { // Build query params const params = new URLSearchParams(); if (selectedDevice !== "all") { params.set("username", selectedDevice); } // Apply time filter based on mode if (filterMode === "custom" && startTime && endTime) { // Convert datetime-local to ISO string const startISO = new Date(startTime).toISOString(); const endISO = new Date(endTime).toISOString(); params.set("startTime", startISO); params.set("endTime", endISO); } else if (filterMode === "quick" && timeFilter > 0) { params.set("timeRangeHours", timeFilter.toString()); } params.set("limit", "5000"); // Fetch more data for better history // Fetch from local SQLite API (MQTT subscriber writes directly) const response = await fetch(`/api/locations?${params.toString()}`); if (!response.ok) throw new Error("Failed to fetch locations"); const data: LocationResponse = await response.json(); // Debug: Log last 3 locations to see speed/battery values if (data.history && data.history.length > 0) { console.log('[MapView Debug] Last 3 locations:', data.history.slice(0, 3).map(loc => ({ username: loc.username, timestamp: loc.timestamp, speed: loc.speed, speed_type: typeof loc.speed, speed_is_null: loc.speed === null, speed_is_undefined: loc.speed === undefined, battery: loc.battery, }))); // Auto-center to latest location const latest = data.history[0]; if (latest && latest.latitude && latest.longitude) { setMapCenter([Number(latest.latitude), Number(latest.longitude)]); } } setLocations(data.history || []); setError(null); } catch (err) { setError("Failed to load locations"); console.error(err); } finally { setLoading(false); } }; fetchLocations(); // Store interval reference for pause/resume control if (!isPaused) { intervalRef.current = setInterval(fetchLocations, 5000); // Refresh every 5s } return () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } }; }, [selectedDevice, timeFilter, isPaused, filterMode, startTime, endTime]); // No client-side filtering needed - API already filters by username and timeRangeHours // Filter out locations without username (should not happen, but TypeScript safety) const filteredLocations = locations.filter(loc => loc.username != null); // Group by device const deviceGroups = filteredLocations.reduce((acc, loc) => { const deviceId = loc.username!; // Safe to use ! here because we filtered null above if (!acc[deviceId]) acc[deviceId] = []; acc[deviceId].push(loc); return acc; }, {} as Record); if (loading) { return (

Loading map...

); } if (error) { return (

{error}

); } return (
{/* Auto-center to latest position and track zoom */} {Object.entries(deviceGroups).map(([deviceId, locs]) => { // Use device from API if available, fallback to hardcoded const device = devices[deviceId] || getDevice(deviceId); // Sort DESC (newest first) - same as API const sortedLocs = [...locs].sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); return (
{/* Polyline for path - reverse for chronological drawing (oldest to newest) */} [ Number(loc.latitude), Number(loc.longitude), ])} color={device.color} weight={2} opacity={0.6} /> {/* Markers - render with explicit z-index (newest on top) */} {sortedLocs.map((loc, idx) => { const isLatest = idx === 0; // First in sorted array = newest (DESC order) // Calculate z-index: newer locations get higher z-index const zIndexOffset = sortedLocs.length - idx; // Debug: Log for latest location only if (isLatest) { console.log('[Popup Debug] Latest location for', device.name, { speed: loc.speed, speed_type: typeof loc.speed, speed_is_null: loc.speed === null, speed_is_undefined: loc.speed === undefined, condition_result: loc.speed != null, display_time: loc.display_time }); } return (

📱 {device.name}

🕒 {loc.display_time}

{loc.battery != null && Number(loc.battery) > 0 && (

🔋 Battery: {loc.battery}%

)} {loc.speed != null && (

🚗 Speed: {Number(loc.speed).toFixed(1)} km/h

)}
); })}
); })}
); } // Helper to create custom icon (similar to original) function createCustomIcon(color: string, isLatest: boolean, zoom: number) { // Base size - much bigger than before const baseSize = isLatest ? 64 : 32; // Zoom-based scaling: smaller at zoom 10, larger at zoom 18+ // zoom 10 = 0.6x, zoom 12 = 1.0x, zoom 15 = 1.45x, zoom 18 = 1.9x const zoomScale = 0.6 + ((zoom - 10) * 0.15); const clampedScale = Math.max(0.5, Math.min(2.5, zoomScale)); // Clamp between 0.5x and 2.5x const size = Math.round(baseSize * clampedScale); // Standard Location Pin Icon (wie Google Maps/Standard Marker) const svg = ` `; return L.divIcon({ html: svg, iconSize: [size, size * 1.5], // Height 1.5x width for pin shape iconAnchor: [size / 2, size * 1.5], // Bottom center point popupAnchor: [0, -size * 1.2], // Popup above the pin className: isLatest ? "custom-marker-icon latest-marker" : "custom-marker-icon", }); }