feat: implement hotspot visualization on map

- Add HotspotSeverity type and HotspotWithSeverity interface
- Implement getHotspots() method in analyticsService with severity calculation
- Add hotspot layer to MapView component with color-coded circles
- Create hotspot markers with severity icons and report count badges
- Add detailed hotspot popups showing report count, severity score, radius, and dates
- Implement hotspot toggle control in MapFilters with loading state
- Add hotspot severity legend to MapFilters
- Integrate hotspot data fetching in MapPage with sample fallback data
- Color-code hotspot boundaries: yellow (low), orange (medium/high), red (critical)
- Display hotspot statistics in interactive popups

Implements task 12: Create hotspot visualization
Requirements: 3.2, 3.3, 3.5
This commit is contained in:
2025-11-16 19:01:26 +00:00
parent 82635559db
commit 40a96142ba
6 changed files with 371 additions and 6 deletions

View File

@@ -8,6 +8,9 @@ interface MapFiltersProps {
showClusters: boolean;
onToggleHeatmap: () => void;
showHeatmap: boolean;
onToggleHotspots?: () => void;
showHotspots?: boolean;
isLoadingHotspots?: boolean;
}
export function MapFilters({
@@ -17,6 +20,9 @@ export function MapFilters({
showClusters,
onToggleHeatmap,
showHeatmap,
onToggleHotspots,
showHotspots = false,
isLoadingHotspots = false,
}: MapFiltersProps) {
const [isExpanded, setIsExpanded] = useState(false);
@@ -153,6 +159,62 @@ export function MapFilters({
</button>
</div>
{/* Hotspots Toggle */}
{onToggleHotspots && (
<div className="flex items-center justify-between">
<label htmlFor="hotspots-toggle" className="text-sm font-medium text-gray-700 flex items-center gap-2">
Hotspots
{isLoadingHotspots && (
<svg className="animate-spin h-4 w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
</label>
<button
id="hotspots-toggle"
onClick={onToggleHotspots}
disabled={isLoadingHotspots}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showHotspots ? 'bg-orange-600' : 'bg-gray-200'
} ${isLoadingHotspots ? 'opacity-50 cursor-not-allowed' : ''}`}
role="switch"
aria-checked={showHotspots}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showHotspots ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
)}
{/* Hotspot Legend */}
{showHotspots && onToggleHotspots && (
<div className="pt-2 border-t border-gray-200">
<p className="text-xs font-medium text-gray-700 mb-2">Hotspot Severity</p>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded-full bg-yellow-300 border border-yellow-400"></div>
<span className="text-gray-600">Low (5-9 reports)</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded-full bg-orange-400 border border-orange-500"></div>
<span className="text-gray-600">Medium (10-14 reports)</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div>
<span className="text-gray-600">High (15-19 reports)</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded-full bg-red-600 border border-red-700"></div>
<span className="text-gray-600">Critical (20+ reports)</span>
</div>
</div>
</div>
)}
{/* Date Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">

View File

@@ -5,7 +5,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import 'leaflet.markercluster';
import 'leaflet.heat';
import type { Report, CleanupEvent, FilterState } from '@/types';
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';
@@ -22,11 +22,13 @@ L.Icon.Default.mergeOptions({
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;
}
@@ -34,11 +36,13 @@ interface MapViewProps {
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) {
@@ -47,6 +51,7 @@ export function MapView({
const markersLayerRef = useRef<L.LayerGroup | null>(null);
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null);
const heatLayerRef = useRef<L.HeatLayer | null>(null);
const hotspotsLayerRef = useRef<L.LayerGroup | null>(null);
// Initialize map
useEffect(() => {
@@ -71,6 +76,9 @@ export function MapView({
// 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
@@ -103,6 +111,7 @@ export function MapView({
markersLayerRef.current = null;
clusterGroupRef.current = null;
heatLayerRef.current = null;
hotspotsLayerRef.current = null;
};
}, []);
@@ -276,6 +285,87 @@ export function MapView({
}
}, [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(`
<div class="p-3 min-w-[200px]">
<h3 class="font-bold text-base mb-2 flex items-center gap-2">
<span class="text-2xl">${getHotspotIcon(hotspot.severity)}</span>
<span>${getSeverityLabel(hotspot.severity)} Hotspot</span>
</h3>
<div class="space-y-1 text-sm">
<p class="flex justify-between">
<span class="text-gray-600">Reports:</span>
<span class="font-semibold">${hotspot.reportCount}</span>
</p>
<p class="flex justify-between">
<span class="text-gray-600">Severity Score:</span>
<span class="font-semibold">${hotspot.severityScore.toFixed(2)}</span>
</p>
<p class="flex justify-between">
<span class="text-gray-600">Radius:</span>
<span class="font-semibold">${hotspot.radius}m</span>
</p>
<p class="flex justify-between">
<span class="text-gray-600">Last Report:</span>
<span class="font-semibold">${lastReportDate}</span>
</p>
<p class="text-xs text-gray-500 mt-2 pt-2 border-t">
Calculated: ${calculatedDate}
</p>
</div>
</div>
`);
// 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) {
@@ -457,3 +547,111 @@ 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: `
<div style="
width: 40px;
height: 40px;
background-color: ${color};
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 3px 6px rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: bold;
position: relative;
">
${icon}
<div style="
position: absolute;
bottom: -8px;
right: -8px;
background-color: white;
color: ${color};
border: 2px solid ${color};
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
">
${reportCount}
</div>
</div>
`,
iconSize: [40, 40],
iconAnchor: [20, 20],
});
}
// Helper function to get hotspot icon based on severity
function getHotspotIcon(severity: string): string {
const icons: Record<string, string> = {
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<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
critical: 'Critical',
};
return labels[severity] || 'Unknown';
}