Files
ocean-cleanup/src/components/map/MapView.tsx
2025-11-16 16:00:51 +00:00

405 lines
12 KiB
TypeScript

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 type { Report, CleanupEvent, FilterState } 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[];
filters?: FilterState;
onMarkerClick?: (reportId: string) => void;
onMapClick?: (lat: number, lng: number) => void;
showHeatmap?: boolean;
showClusters?: boolean;
center?: [number, number];
zoom?: number;
}
export function MapView({
reports,
events = [],
filters,
onMarkerClick,
onMapClick,
showClusters = true,
center = [53.3498, -6.2603], // Dublin, Ireland default
zoom = 7,
}: MapViewProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const markersLayerRef = useRef<L.LayerGroup | null>(null);
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 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: `<div><span>${count}</span></div>`,
className: `marker-cluster ${sizeClass}`,
iconSize: L.point(40, 40),
});
},
});
mapInstanceRef.current = map;
return () => {
map.remove();
mapInstanceRef.current = null;
markersLayerRef.current = null;
clusterGroupRef.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
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-sm mb-1">${getLitterTypeLabel(report.litterType)}</h3>
<p class="text-xs text-gray-600">Quantity: ${report.quantity}</p>
<p class="text-xs text-gray-600">Status: ${report.verificationStatus}</p>
<p class="text-xs text-gray-600">Date: ${new Date(report.createdAt).toLocaleDateString()}</p>
${report.description ? `<p class="text-xs mt-1">${report.description}</p>` : ''}
</div>
`);
// 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(`
<div class="p-2">
<h3 class="font-bold text-sm mb-1">${event.title}</h3>
<p class="text-xs text-gray-600">${event.locationName}</p>
<p class="text-xs text-gray-600">Participants: ${event.participantCount}</p>
<p class="text-xs text-gray-600">Status: ${event.status}</p>
</div>
`);
markersLayer.addLayer(marker);
});
// Add appropriate layer to map
if (showClusters) {
clusterGroup.addTo(map);
}
markersLayer.addTo(map);
}, [reports, events, filters, showClusters, onMarkerClick]);
// 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: '<div class="w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg"></div>',
iconSize: [16, 16],
}),
}).addTo(mapInstanceRef.current);
}
},
(error) => {
alert(`Error getting location: ${error.message}`);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
);
};
return (
<div className="relative w-full h-full">
<div ref={mapRef} className="w-full h-full rounded-lg" />
{/* Geolocation button */}
<button
onClick={handleGeolocation}
className="absolute top-4 right-4 z-[1000] bg-white hover:bg-gray-50 text-gray-700 p-3 rounded-lg shadow-lg transition-colors"
title="Center map on your location"
aria-label="Center map on your location"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
);
}
// 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: `
<div style="
width: ${size}px;
height: ${size}px;
background-color: ${color};
border: 2px solid white;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
">
<div style="
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(45deg);
color: white;
font-size: ${size * 0.4}px;
font-weight: bold;
">
${getLitterTypeIcon(litterType)}
</div>
</div>
`,
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: `
<div style="
width: 32px;
height: 32px;
background-color: ${color};
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
font-weight: bold;
">
📅
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
}
// Helper function to get litter type icon
function getLitterTypeIcon(litterType: string): string {
const icons: Record<string, string> = {
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;
}