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:
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user