import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import 'leaflet.markercluster/dist/MarkerCluster.css'; import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; import 'leaflet.markercluster'; import 'leaflet.heat'; import type { Report, CleanupEvent, FilterState, HotspotWithSeverity } from '@/types'; // Fix for default marker icons in Leaflet with Vite import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow, }); interface MapViewProps { reports: Report[]; events?: CleanupEvent[]; hotspots?: HotspotWithSeverity[]; filters?: FilterState; onMarkerClick?: (reportId: string) => void; onMapClick?: (lat: number, lng: number) => void; showHeatmap?: boolean; showClusters?: boolean; showHotspots?: boolean; center?: [number, number]; zoom?: number; } export function MapView({ reports, events = [], hotspots = [], filters, onMarkerClick, onMapClick, showClusters = true, showHeatmap = false, showHotspots = false, center = [53.3498, -6.2603], // Dublin, Ireland default zoom = 7, }: MapViewProps) { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const markersLayerRef = useRef(null); const clusterGroupRef = useRef(null); const heatLayerRef = useRef(null); const hotspotsLayerRef = useRef(null); // Initialize map useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return; // Create map instance const map = L.map(mapRef.current).setView(center, zoom); // Add OpenStreetMap tiles L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(map); // Handle map clicks if (onMapClick) { map.on('click', (e: L.LeafletMouseEvent) => { onMapClick(e.latlng.lat, e.latlng.lng); }); } // Create markers layer markersLayerRef.current = L.layerGroup(); // Create hotspots layer hotspotsLayerRef.current = L.layerGroup(); // Create cluster group with custom configuration clusterGroupRef.current = L.markerClusterGroup({ maxClusterRadius: 50, // Cluster markers within 50 pixels spiderfyOnMaxZoom: true, showCoverageOnHover: true, zoomToBoundsOnClick: true, iconCreateFunction: (cluster) => { const count = cluster.getChildCount(); let sizeClass = 'marker-cluster-small'; if (count > 10) { sizeClass = 'marker-cluster-large'; } else if (count > 5) { sizeClass = 'marker-cluster-medium'; } return L.divIcon({ html: `
${count}
`, className: `marker-cluster ${sizeClass}`, iconSize: L.point(40, 40), }); }, }); mapInstanceRef.current = map; return () => { map.remove(); mapInstanceRef.current = null; markersLayerRef.current = null; clusterGroupRef.current = null; heatLayerRef.current = null; hotspotsLayerRef.current = null; }; }, []); // Filter reports based on filter state const getFilteredReports = (): Report[] => { if (!filters) return reports; return reports.filter((report) => { // Filter by date range if (filters.dateRange) { const reportDate = new Date(report.createdAt); if (filters.dateRange.start && reportDate < filters.dateRange.start) { return false; } if (filters.dateRange.end && reportDate > filters.dateRange.end) { return false; } } // Filter by litter type if (filters.litterTypes && filters.litterTypes.length > 0) { if (!filters.litterTypes.includes(report.litterType)) { return false; } } // Filter by quantity if (filters.quantities && filters.quantities.length > 0) { if (!filters.quantities.includes(report.quantity)) { return false; } } // Filter by verification status if (filters.verificationStatus && filters.verificationStatus.length > 0) { if (!filters.verificationStatus.includes(report.verificationStatus)) { return false; } } return true; }); }; // Update markers when reports, filters, or clustering changes useEffect(() => { if (!mapInstanceRef.current || !markersLayerRef.current || !clusterGroupRef.current) return; const map = mapInstanceRef.current; const markersLayer = markersLayerRef.current; const clusterGroup = clusterGroupRef.current; // Remove existing layers from map if (map.hasLayer(markersLayer)) { map.removeLayer(markersLayer); } if (map.hasLayer(clusterGroup)) { map.removeLayer(clusterGroup); } // Clear existing markers markersLayer.clearLayers(); clusterGroup.clearLayers(); // Get filtered reports const filteredReports = getFilteredReports(); // Add report markers filteredReports.forEach((report) => { const icon = createCustomMarkerIcon(report.litterType, report.quantity); const marker = L.marker([report.latitude, report.longitude], { icon }); // Add popup with report details const photoUrl = report.photoUrls && report.photoUrls.length > 0 ? report.photoUrls[0] : null; // Remove /api from the base URL for uploads since they're served at the root level const serverBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api').replace('/api', ''); const fullPhotoUrl = photoUrl ? (photoUrl.startsWith('http') ? photoUrl : `${serverBaseUrl}${photoUrl}`) : null; marker.bindPopup(`
${fullPhotoUrl ? ` Litter photo ` : ''}

${getLitterTypeLabel(report.litterType)}

Quantity: ${report.quantity}

Status: ${report.verificationStatus}

Date: ${new Date(report.createdAt).toLocaleDateString()}

${report.description ? `

${report.description}

` : ''}
`); // Handle marker click if (onMarkerClick) { marker.on('click', () => { onMarkerClick(report.id); }); } // Add to appropriate layer if (showClusters) { clusterGroup.addLayer(marker); } else { markersLayer.addLayer(marker); } }); // Add event markers (always to regular layer, not clustered) events.forEach((event) => { const icon = createEventMarkerIcon(event.status); const marker = L.marker([event.latitude, event.longitude], { icon }); marker.bindPopup(`

${event.title}

${event.locationName}

Participants: ${event.participantCount}

Status: ${event.status}

`); markersLayer.addLayer(marker); }); // Add appropriate layer to map if (showClusters) { clusterGroup.addTo(map); } markersLayer.addTo(map); }, [reports, events, filters, showClusters, onMarkerClick]); // Update heat map when reports, filters, or showHeatmap changes useEffect(() => { if (!mapInstanceRef.current) return; const map = mapInstanceRef.current; // Remove existing heat layer if it exists if (heatLayerRef.current && map.hasLayer(heatLayerRef.current)) { map.removeLayer(heatLayerRef.current); heatLayerRef.current = null; } // Only create heat layer if showHeatmap is true if (showHeatmap) { const filteredReports = getFilteredReports(); // Convert reports to heat map data points // Format: [latitude, longitude, intensity] const heatData: [number, number, number][] = filteredReports.map((report) => { // Calculate intensity based on quantity level const intensityMap = { minimal: 0.3, moderate: 0.5, significant: 0.7, severe: 1.0, }; const intensity = intensityMap[report.quantity] || 0.5; return [report.latitude, report.longitude, intensity]; }); // Create heat layer with configuration if (heatData.length > 0) { heatLayerRef.current = L.heatLayer(heatData, { radius: 25, // Radius of each heat point in pixels blur: 15, // Amount of blur maxZoom: 17, // Maximum zoom level for heat layer max: 1.0, // Maximum intensity value gradient: { 0.0: '#0000ff', // Blue for low intensity 0.3: '#00ffff', // Cyan 0.5: '#00ff00', // Green 0.7: '#ffff00', // Yellow 0.9: '#ff8800', // Orange 1.0: '#ff0000', // Red for high intensity }, }).addTo(map); } } }, [reports, filters, showHeatmap]); // Update hotspots when hotspots data or showHotspots changes useEffect(() => { if (!mapInstanceRef.current || !hotspotsLayerRef.current) return; const map = mapInstanceRef.current; const hotspotsLayer = hotspotsLayerRef.current; // Remove existing hotspots layer if it exists if (map.hasLayer(hotspotsLayer)) { map.removeLayer(hotspotsLayer); } // Clear existing hotspots hotspotsLayer.clearLayers(); // Only create hotspot layer if showHotspots is true if (showHotspots && hotspots.length > 0) { hotspots.forEach((hotspot) => { // Get color based on severity const { color, fillColor, opacity } = getHotspotStyle(hotspot.severity); // Create circle for hotspot boundary const circle = L.circle([hotspot.latitude, hotspot.longitude], { radius: hotspot.radius, color: color, fillColor: fillColor, fillOpacity: opacity, weight: 2, }); // Create popup with hotspot details const lastReportDate = new Date(hotspot.lastReportDate).toLocaleDateString(); const calculatedDate = new Date(hotspot.calculatedAt).toLocaleDateString(); circle.bindPopup(`

${getHotspotIcon(hotspot.severity)} ${getSeverityLabel(hotspot.severity)} Hotspot

Reports: ${hotspot.reportCount}

Severity Score: ${hotspot.severityScore.toFixed(2)}

Radius: ${hotspot.radius}m

Last Report: ${lastReportDate}

Calculated: ${calculatedDate}

`); // Add center marker for hotspot const centerMarker = L.marker([hotspot.latitude, hotspot.longitude], { icon: createHotspotMarkerIcon(hotspot.severity, hotspot.reportCount), }); centerMarker.bindPopup(circle.getPopup()!); // Add to layer hotspotsLayer.addLayer(circle); hotspotsLayer.addLayer(centerMarker); }); // Add hotspots layer to map hotspotsLayer.addTo(map); } }, [hotspots, showHotspots]); // Handle geolocation const handleGeolocation = () => { if (!navigator.geolocation) { alert('Geolocation is not supported by your browser'); return; } navigator.geolocation.getCurrentPosition( (position) => { const coords: [number, number] = [ position.coords.latitude, position.coords.longitude, ]; if (mapInstanceRef.current) { mapInstanceRef.current.setView(coords, 13); // Add user location marker L.marker(coords, { icon: L.divIcon({ className: 'user-location-marker', html: '
', iconSize: [16, 16], }), }).addTo(mapInstanceRef.current); } }, (error) => { alert(`Error getting location: ${error.message}`); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0, } ); }; return (
{/* Geolocation button */}
); } // Helper function to create custom marker icons based on litter type function createCustomMarkerIcon(litterType: string, quantity: string): L.DivIcon { const colors = { plastic: '#3B82F6', // blue metal: '#6B7280', // gray glass: '#10B981', // green organic: '#84CC16', // lime hazardous: '#EF4444', // red other: '#8B5CF6', // purple }; const sizes = { minimal: 24, moderate: 28, significant: 32, severe: 36, }; const color = colors[litterType as keyof typeof colors] || colors.other; const size = sizes[quantity as keyof typeof sizes] || sizes.moderate; return L.divIcon({ className: 'custom-marker', html: `
${getLitterTypeIcon(litterType)}
`, iconSize: [size, size], iconAnchor: [size / 2, size], }); } // Helper function to create event marker icons function createEventMarkerIcon(status: string): L.DivIcon { const colors = { upcoming: '#F59E0B', // amber completed: '#10B981', // green cancelled: '#6B7280', // gray }; const color = colors[status as keyof typeof colors] || colors.upcoming; return L.divIcon({ className: 'event-marker', html: `
📅
`, iconSize: [32, 32], iconAnchor: [16, 16], }); } // Helper function to get litter type icon function getLitterTypeIcon(litterType: string): string { const icons: Record = { plastic: '♻', metal: '⚙', glass: '◆', organic: '🌿', hazardous: '⚠', other: '●', }; const icon = icons[litterType]; return icon !== undefined ? icon : '●'; } // Helper function to get litter type label function getLitterTypeLabel(litterType: string): string { const label = litterType.charAt(0).toUpperCase() + litterType.slice(1); return label; } // Helper function to get hotspot style based on severity function getHotspotStyle(severity: string): { color: string; fillColor: string; opacity: number } { const styles = { low: { color: '#FCD34D', // yellow-300 fillColor: '#FEF3C7', // yellow-100 opacity: 0.3, }, medium: { color: '#FB923C', // orange-400 fillColor: '#FFEDD5', // orange-100 opacity: 0.4, }, high: { color: '#F97316', // orange-500 fillColor: '#FED7AA', // orange-200 opacity: 0.5, }, critical: { color: '#DC2626', // red-600 fillColor: '#FEE2E2', // red-100 opacity: 0.6, }, }; return styles[severity as keyof typeof styles] || styles.low; } // Helper function to create hotspot marker icon function createHotspotMarkerIcon(severity: string, reportCount: number): L.DivIcon { const colors = { low: '#FCD34D', medium: '#FB923C', high: '#F97316', critical: '#DC2626', }; const color = colors[severity as keyof typeof colors] || colors.low; const icon = getHotspotIcon(severity); return L.divIcon({ className: 'hotspot-marker', html: `
${icon}
${reportCount}
`, iconSize: [40, 40], iconAnchor: [20, 20], }); } // Helper function to get hotspot icon based on severity function getHotspotIcon(severity: string): string { const icons: Record = { low: '⚠️', medium: '🔶', high: '🔥', critical: '🚨', }; const icon = icons[severity]; return icon !== undefined ? icon : '⚠️'; } // Helper function to get severity label function getSeverityLabel(severity: string): string { const labels: Record = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical', }; return labels[severity] || 'Unknown'; }