Initial Commit
This commit is contained in:
404
src/components/map/MapView.tsx
Normal file
404
src/components/map/MapView.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
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: '© <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;
|
||||
}
|
||||
Reference in New Issue
Block a user