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:
@@ -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">
|
||||
|
||||
@@ -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