Initial Commit

This commit is contained in:
2025-11-16 16:00:51 +00:00
commit ba324b370e
52 changed files with 8014 additions and 0 deletions

13
src/components/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Components Directory
This directory contains all React components for the Beach Litter Mapping Platform.
## Structure
Components will be organized by feature:
- `map/` - Map-related components (MapInterface, markers, clusters, etc.)
- `reports/` - Report-related components (ReportForm, ReportDetail, etc.)
- `events/` - Event management components
- `analytics/` - Analytics and dashboard components
- `user/` - User profile and authentication components
- `common/` - Shared/reusable components (buttons, modals, forms, etc.)

View File

@@ -0,0 +1,36 @@
import { useAuth } from '@clerk/clerk-react';
import { Navigate, useLocation } from 'react-router-dom';
import { ReactNode } from 'react';
interface ProtectedRouteProps {
children: ReactNode;
redirectTo?: string;
}
/**
* Protected route wrapper that requires authentication
* Redirects to sign-in page if user is not authenticated
*/
export function ProtectedRoute({
children,
redirectTo = '/sign-in'
}: ProtectedRouteProps) {
const { isLoaded, isSignedIn } = useAuth();
const location = useLocation();
// Wait for Clerk to load
if (!isLoaded) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
// Redirect to sign-in if not authenticated
if (!isSignedIn) {
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,43 @@
import { SignIn } from '@clerk/clerk-react';
import { useNavigate, useLocation } from 'react-router-dom';
/**
* Sign-in page with Clerk authentication
*/
export function SignInPage() {
const navigate = useNavigate();
const location = useLocation();
// Get the redirect path from location state, default to home
const from = (location.state as any)?.from?.pathname || '/';
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to Beach Litter Mapping
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Join the community in tracking and cleaning coastal pollution
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<SignIn
routing="path"
path="/sign-in"
signUpUrl="/sign-up"
afterSignInUrl={from}
appearance={{
elements: {
rootBox: 'w-full',
card: 'shadow-none',
},
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { SignUp } from '@clerk/clerk-react';
/**
* Sign-up page with Clerk authentication
*/
export function SignUpPage() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Start reporting and tracking beach litter in your area
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<SignUp
routing="path"
path="/sign-up"
signInUrl="/sign-in"
afterSignUpUrl="/"
appearance={{
elements: {
rootBox: 'w-full',
card: 'shadow-none',
},
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useUser, useClerk } from '@clerk/clerk-react';
import { useNavigate } from 'react-router-dom';
/**
* User profile display component showing user info and sign-out option
*/
export function UserProfile() {
const { user, isLoaded } = useUser();
const { signOut } = useClerk();
const navigate = useNavigate();
const handleSignOut = async () => {
await signOut();
navigate('/');
};
if (!isLoaded) {
return (
<div className="flex items-center space-x-3">
<div className="animate-pulse">
<div className="h-10 w-10 bg-gray-300 rounded-full"></div>
</div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3">
{user.imageUrl && (
<img
src={user.imageUrl}
alt={user.fullName || user.username || 'User'}
className="h-10 w-10 rounded-full object-cover border-2 border-gray-200"
/>
)}
<div className="hidden md:block">
<p className="text-sm font-medium text-gray-900">
{user.fullName || user.username || 'User'}
</p>
<p className="text-xs text-gray-500">
{user.primaryEmailAddress?.emailAddress}
</p>
</div>
</div>
<button
onClick={handleSignOut}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Sign Out
</button>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { ProtectedRoute } from './ProtectedRoute';
export { UserProfile } from './UserProfile';
export { SignInPage } from './SignInPage';
export { SignUpPage } from './SignUpPage';

8
src/components/index.ts Normal file
View File

@@ -0,0 +1,8 @@
// Auth components
export * from './auth';
// Layout components
export * from './layout';
// Map components
export * from './map';

View File

@@ -0,0 +1,73 @@
import { SignedIn, SignedOut, SignInButton, SignUpButton } from '@clerk/clerk-react';
import { Link } from 'react-router-dom';
import { UserProfile } from '../auth/UserProfile';
/**
* Application header with navigation and authentication controls
*/
export function Header() {
return (
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div className="flex items-center">
<Link to="/" className="flex items-center">
<h1 className="text-2xl font-bold text-blue-600">
Beach Litter Mapping
</h1>
</Link>
</div>
<nav className="flex items-center space-x-4">
<SignedIn>
<Link
to="/map"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Map
</Link>
<Link
to="/reports/new"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Report Litter
</Link>
<Link
to="/events"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Events
</Link>
<Link
to="/analytics"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
Analytics
</Link>
<UserProfile />
</SignedIn>
<SignedOut>
<Link
to="/map"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium"
>
View Map
</Link>
<SignInButton mode="modal">
<button className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign In
</button>
</SignInButton>
<SignUpButton mode="modal">
<button className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign Up
</button>
</SignUpButton>
</SignedOut>
</nav>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { Header } from './Header';
interface LayoutProps {
children: ReactNode;
}
/**
* Main layout wrapper with header
*/
export function Layout({ children }: LayoutProps) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main>{children}</main>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { Header } from './Header';
export { Layout } from './Layout';

View File

@@ -0,0 +1,217 @@
import { useState } from 'react';
import type { FilterState, LitterType, QuantityLevel } from '@/types';
interface MapFiltersProps {
filters: FilterState;
onFiltersChange: (filters: FilterState) => void;
onToggleClusters: () => void;
showClusters: boolean;
}
export function MapFilters({
filters,
onFiltersChange,
onToggleClusters,
showClusters,
}: MapFiltersProps) {
const [isExpanded, setIsExpanded] = useState(false);
const litterTypes: LitterType[] = ['plastic', 'metal', 'glass', 'organic', 'hazardous', 'other'];
const quantities: QuantityLevel[] = ['minimal', 'moderate', 'significant', 'severe'];
const handleLitterTypeToggle = (type: LitterType) => {
const currentTypes = filters.litterTypes || [];
const newTypes = currentTypes.includes(type)
? currentTypes.filter(t => t !== type)
: [...currentTypes, type];
onFiltersChange({
...filters,
litterTypes: newTypes.length > 0 ? newTypes : undefined,
});
};
const handleQuantityToggle = (quantity: QuantityLevel) => {
const currentQuantities = filters.quantities || [];
const newQuantities = currentQuantities.includes(quantity)
? currentQuantities.filter(q => q !== quantity)
: [...currentQuantities, quantity];
onFiltersChange({
...filters,
quantities: newQuantities.length > 0 ? newQuantities : undefined,
});
};
const handleDateRangeChange = (field: 'start' | 'end', value: string) => {
if (!value) {
if (field === 'start' && !filters.dateRange?.end) {
onFiltersChange({ ...filters, dateRange: undefined });
} else if (field === 'end' && !filters.dateRange?.start) {
onFiltersChange({ ...filters, dateRange: undefined });
} else {
onFiltersChange({
...filters,
dateRange: {
start: field === 'start' ? new Date() : filters.dateRange!.start,
end: field === 'end' ? new Date() : filters.dateRange!.end,
[field]: undefined,
} as any,
});
}
return;
}
const date = new Date(value);
onFiltersChange({
...filters,
dateRange: {
start: field === 'start' ? date : filters.dateRange?.start || date,
end: field === 'end' ? date : filters.dateRange?.end || date,
},
});
};
const handleClearFilters = () => {
onFiltersChange({});
};
const hasActiveFilters =
(filters.litterTypes && filters.litterTypes.length > 0) ||
(filters.quantities && filters.quantities.length > 0) ||
filters.dateRange;
return (
<div className="absolute top-4 left-4 z-[1000] bg-white rounded-lg shadow-lg max-w-sm">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Filters</h3>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-gray-500 hover:text-gray-700"
aria-label={isExpanded ? 'Collapse filters' : 'Expand filters'}
>
<svg
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Filter Content */}
{isExpanded && (
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Cluster Toggle */}
<div className="flex items-center justify-between">
<label htmlFor="cluster-toggle" className="text-sm font-medium text-gray-700">
Cluster Markers
</label>
<button
id="cluster-toggle"
onClick={onToggleClusters}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showClusters ? 'bg-blue-600' : 'bg-gray-200'
}`}
role="switch"
aria-checked={showClusters}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showClusters ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Date Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date Range
</label>
<div className="space-y-2">
<input
type="date"
value={filters.dateRange?.start ? filters.dateRange.start.toISOString().split('T')[0] : ''}
onChange={(e) => handleDateRangeChange('start', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Start date"
/>
<input
type="date"
value={filters.dateRange?.end ? filters.dateRange.end.toISOString().split('T')[0] : ''}
onChange={(e) => handleDateRangeChange('end', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="End date"
/>
</div>
</div>
{/* Litter Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Litter Type
</label>
<div className="space-y-2">
{litterTypes.map((type) => (
<label key={type} className="flex items-center">
<input
type="checkbox"
checked={filters.litterTypes?.includes(type) || false}
onChange={() => handleLitterTypeToggle(type)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700 capitalize">{type}</span>
</label>
))}
</div>
</div>
{/* Quantity Level */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quantity Level
</label>
<div className="space-y-2">
{quantities.map((quantity) => (
<label key={quantity} className="flex items-center">
<input
type="checkbox"
checked={filters.quantities?.includes(quantity) || false}
onChange={() => handleQuantityToggle(quantity)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700 capitalize">{quantity}</span>
</label>
))}
</div>
</div>
{/* Clear Filters Button */}
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Clear All Filters
</button>
)}
</div>
)}
{/* Active Filters Badge */}
{!isExpanded && hasActiveFilters && (
<div className="px-4 py-2 text-xs text-blue-600 font-medium">
{[
filters.litterTypes?.length && `${filters.litterTypes.length} type(s)`,
filters.quantities?.length && `${filters.quantities.length} quantity`,
filters.dateRange && 'date range',
].filter(Boolean).join(', ')}
</div>
)}
</div>
);
}

View 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: '&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;
}

View File

@@ -0,0 +1,80 @@
# Map Components
## MapView
Interactive map component built with Leaflet.js for displaying litter reports and cleanup events.
### Features
- **OpenStreetMap Integration**: Uses free OpenStreetMap tiles
- **Custom Markers**: Color-coded markers for different litter types with size based on quantity
- **Geolocation**: Button to center map on user's current location
- **Interactive Popups**: Click markers to view report details
- **Event Markers**: Display cleanup events with distinct styling
- **Zoom & Pan Controls**: Standard Leaflet map controls
### Usage
```tsx
import { MapView } from '@/components/map';
function MyMapPage() {
const reports = [/* array of Report objects */];
const events = [/* array of CleanupEvent objects */];
const handleMarkerClick = (reportId: string) => {
console.log('Report clicked:', reportId);
};
return (
<MapView
reports={reports}
events={events}
onMarkerClick={handleMarkerClick}
center={[53.3498, -6.2603]} // Optional: default center
zoom={7} // Optional: default zoom level
/>
);
}
```
### Props
- `reports` (Report[]): Array of litter reports to display
- `events` (CleanupEvent[]): Array of cleanup events to display (optional)
- `filters` (FilterState): Filter state for reports (optional, for future use)
- `onMarkerClick` ((reportId: string) => void): Callback when a report marker is clicked (optional)
- `onMapClick` ((lat: number, lng: number) => void): Callback when the map is clicked (optional)
- `showHeatmap` (boolean): Toggle heat map overlay (optional, for future use)
- `showClusters` (boolean): Toggle marker clustering (optional, for future use)
- `center` ([number, number]): Initial map center coordinates (default: Dublin, Ireland)
- `zoom` (number): Initial zoom level (default: 7)
### Marker Colors
- **Plastic**: Blue (#3B82F6)
- **Metal**: Gray (#6B7280)
- **Glass**: Green (#10B981)
- **Organic**: Lime (#84CC16)
- **Hazardous**: Red (#EF4444)
- **Other**: Purple (#8B5CF6)
### Marker Sizes (by quantity)
- **Minimal**: 24px
- **Moderate**: 28px
- **Significant**: 32px
- **Severe**: 36px
### Event Marker Colors
- **Upcoming**: Amber (#F59E0B)
- **Completed**: Green (#10B981)
- **Cancelled**: Gray (#6B7280)
### Requirements Satisfied
- ✅ 2.1: Display litter reports as visual markers on interactive map
- ✅ 2.2: Click markers to display full report details
- ✅ 2.3: Map zoom functionality from country to street level
- ✅ 8.2: Access device GPS to center map on user location

View File

@@ -0,0 +1,2 @@
export { MapView } from './MapView';
export { MapFilters } from './MapFilters';