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 WaterQualityMarker { name: string; latitude: number; longitude: number; status: 'excellent' | 'good' | 'sufficient' | 'poor' | 'unknown'; lastSampled?: string; } interface MapViewProps { reports: Report[]; events?: CleanupEvent[]; hotspots?: HotspotWithSeverity[]; waterQuality?: WaterQualityMarker[]; filters?: FilterState; onMarkerClick?: (reportId: string) => void; onMapClick?: (lat: number, lng: number) => void; showHeatmap?: boolean; showClusters?: boolean; showHotspots?: boolean; showWaterQuality?: boolean; center?: [number, number]; zoom?: number; } export function MapView({ reports, events = [], hotspots = [], waterQuality = [], filters, onMarkerClick, onMapClick, showClusters = true, showHeatmap = false, showHotspots = false, showWaterQuality = false, center = [53.4, -9.0], // Centered on Ireland, minimizing UK visibility 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); const waterQualityLayerRef = 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 water quality layer waterQualityLayerRef.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; waterQualityLayerRef.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; } } // Filter by cleaned status if (filters.showCleaned === false && report.cleanedAt) { 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 isCleaned = !!report.cleanedAt; const icon = createCustomMarkerIcon(report.litterType, report.quantity, isCleaned); 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}

${isCleaned && report.cleanedAt ? `

✓ Cleaned up

Cleaned: ${new Date(report.cleanedAt).toLocaleDateString()}

` : `

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 }); const scheduledDate = new Date(event.scheduledDate).toLocaleDateString('en-IE', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); marker.bindPopup(`

${event.title}

📍 ${event.locationName}

📅 ${scheduledDate}

⏱️ ${event.duration} minutes

👥 ${event.participantCount} participant${event.participantCount !== 1 ? 's' : ''}

${event.status === 'completed' && event.litterCollected ? `

♻️ ${event.litterCollected} kg collected

` : ''}

${event.status.charAt(0).toUpperCase() + event.status.slice(1)}

${event.description ? `

${event.description}

` : ''}
`); 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]); // Update water quality markers when waterQuality data or showWaterQuality changes useEffect(() => { if (!mapInstanceRef.current || !waterQualityLayerRef.current) return; const map = mapInstanceRef.current; const waterQualityLayer = waterQualityLayerRef.current; // Remove existing water quality layer if it exists if (map.hasLayer(waterQualityLayer)) { map.removeLayer(waterQualityLayer); } // Clear existing water quality markers waterQualityLayer.clearLayers(); // Only create water quality layer if showWaterQuality is true if (showWaterQuality && waterQuality.length > 0) { waterQuality.forEach((beach) => { // Create marker for beach const icon = createWaterQualityMarkerIcon(beach.status); const marker = L.marker([beach.latitude, beach.longitude], { icon }); // Create popup with water quality details const lastSampledDate = beach.lastSampled ? new Date(beach.lastSampled).toLocaleDateString('en-IE', { year: 'numeric', month: 'short', day: 'numeric', }) : 'Unknown'; marker.bindPopup(`

💧 ${beach.name}

Water Quality: ${beach.status.charAt(0).toUpperCase() + beach.status.slice(1)}
${beach.lastSampled ? `
Last Sampled: ${lastSampledDate}
` : ''}

Source: EU Bathing Water Quality (2024)

`); // Add to layer waterQualityLayer.addLayer(marker); }); // Add water quality layer to map waterQualityLayer.addTo(map); } }, [waterQuality, showWaterQuality]); // 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, isCleaned: boolean = false): 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, }; // Use green color for cleaned reports, otherwise use litter type color const color = isCleaned ? '#10B981' : (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: `
${isCleaned ? '✓' : 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'; } // Helper function to create water quality marker icons function createWaterQualityMarkerIcon(status: string): L.DivIcon { const colors = { excellent: '#10B981', // green-500 good: '#3B82F6', // blue-500 sufficient: '#F59E0B', // amber-500 poor: '#EF4444', // red-500 unknown: '#6B7280', // gray-500 }; const color = colors[status as keyof typeof colors] || colors.unknown; return L.divIcon({ className: 'water-quality-marker', html: `
💧
`, iconSize: [28, 28], iconAnchor: [14, 14], }); } // Helper function to get water quality badge class function getWaterQualityBadgeClass(status: string): string { const classes = { excellent: 'bg-green-100 text-green-800', good: 'bg-blue-100 text-blue-800', sufficient: 'bg-amber-100 text-amber-800', poor: 'bg-red-100 text-red-800', unknown: 'bg-gray-100 text-gray-800', }; return classes[status as keyof typeof classes] || classes.unknown; }