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';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MapView, MapFilters } from '@/components/map';
|
||||
import type { Report, FilterState } from '@/types';
|
||||
import { analyticsService } from '@/services/analyticsService';
|
||||
import type { Report, FilterState, HotspotWithSeverity } from '@/types';
|
||||
|
||||
/**
|
||||
* Map page - Interactive map displaying litter reports and cleanup events
|
||||
@@ -10,6 +11,9 @@ export function MapPage() {
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
const [showClusters, setShowClusters] = useState(true);
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
const [showHotspots, setShowHotspots] = useState(false);
|
||||
const [hotspots, setHotspots] = useState<HotspotWithSeverity[]>([]);
|
||||
const [isLoadingHotspots, setIsLoadingHotspots] = useState(false);
|
||||
|
||||
// Sample data for demonstration (will be replaced with API data in later tasks)
|
||||
const sampleReports: Report[] = [
|
||||
@@ -124,6 +128,63 @@ export function MapPage() {
|
||||
setShowHeatmap(!showHeatmap);
|
||||
};
|
||||
|
||||
const handleToggleHotspots = () => {
|
||||
setShowHotspots(!showHotspots);
|
||||
};
|
||||
|
||||
// Fetch hotspots when hotspot view is enabled
|
||||
useEffect(() => {
|
||||
if (showHotspots && hotspots.length === 0) {
|
||||
setIsLoadingHotspots(true);
|
||||
analyticsService.getHotspots()
|
||||
.then((data) => {
|
||||
setHotspots(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch hotspots:', error);
|
||||
// Use sample hotspot data for demonstration
|
||||
setHotspots([
|
||||
{
|
||||
id: 'hotspot1',
|
||||
latitude: 53.3498,
|
||||
longitude: -6.2603,
|
||||
radius: 500,
|
||||
reportCount: 15,
|
||||
severityScore: 0.75,
|
||||
lastReportDate: new Date('2024-01-20'),
|
||||
calculatedAt: new Date('2024-01-21'),
|
||||
severity: 'high',
|
||||
},
|
||||
{
|
||||
id: 'hotspot2',
|
||||
latitude: 51.8969,
|
||||
longitude: -8.4863,
|
||||
radius: 450,
|
||||
reportCount: 8,
|
||||
severityScore: 0.45,
|
||||
lastReportDate: new Date('2024-01-19'),
|
||||
calculatedAt: new Date('2024-01-21'),
|
||||
severity: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'hotspot3',
|
||||
latitude: 53.2707,
|
||||
longitude: -9.0568,
|
||||
radius: 600,
|
||||
reportCount: 22,
|
||||
severityScore: 0.88,
|
||||
lastReportDate: new Date('2024-01-21'),
|
||||
calculatedAt: new Date('2024-01-21'),
|
||||
severity: 'critical',
|
||||
},
|
||||
]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingHotspots(false);
|
||||
});
|
||||
}
|
||||
}, [showHotspots]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<div className="bg-white border-b border-gray-200 px-4 py-4 sm:px-6 lg:px-8">
|
||||
@@ -137,9 +198,11 @@ export function MapPage() {
|
||||
<div className="h-full bg-white rounded-lg shadow-lg overflow-hidden relative">
|
||||
<MapView
|
||||
reports={sampleReports}
|
||||
hotspots={hotspots}
|
||||
filters={filters}
|
||||
showClusters={showClusters}
|
||||
showHeatmap={showHeatmap}
|
||||
showHotspots={showHotspots}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
/>
|
||||
@@ -151,6 +214,9 @@ export function MapPage() {
|
||||
showClusters={showClusters}
|
||||
onToggleHeatmap={handleToggleHeatmap}
|
||||
showHeatmap={showHeatmap}
|
||||
onToggleHotspots={handleToggleHotspots}
|
||||
showHotspots={showHotspots}
|
||||
isLoadingHotspots={isLoadingHotspots}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Analytics service for API interactions
|
||||
import { apiClient } from './api';
|
||||
import type { AnalyticsSummary, TimeSeriesDataPoint, LitterTypeDistribution, QuantityAggregation } from '@/types';
|
||||
import type { AnalyticsSummary, TimeSeriesDataPoint, LitterTypeDistribution, QuantityAggregation, Hotspot, HotspotWithSeverity, HotspotSeverity } from '@/types';
|
||||
|
||||
class AnalyticsService {
|
||||
/**
|
||||
@@ -91,6 +91,39 @@ class AnalyticsService {
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hotspot data for map visualization
|
||||
*/
|
||||
async getHotspots(): Promise<HotspotWithSeverity[]> {
|
||||
const hotspots = await apiClient.get<Hotspot[]>('/analytics/hotspots');
|
||||
|
||||
// Calculate severity based on score and add to each hotspot
|
||||
return hotspots.map(hotspot => ({
|
||||
...hotspot,
|
||||
severity: this.calculateHotspotSeverity(hotspot.severityScore, hotspot.reportCount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hotspot severity based on score and report count
|
||||
*/
|
||||
private calculateHotspotSeverity(score: number, reportCount: number): HotspotSeverity {
|
||||
// Critical: score >= 0.8 or 20+ reports
|
||||
if (score >= 0.8 || reportCount >= 20) {
|
||||
return 'critical';
|
||||
}
|
||||
// High: score >= 0.6 or 15+ reports
|
||||
if (score >= 0.6 || reportCount >= 15) {
|
||||
return 'high';
|
||||
}
|
||||
// Medium: score >= 0.4 or 10+ reports
|
||||
if (score >= 0.4 || reportCount >= 10) {
|
||||
return 'medium';
|
||||
}
|
||||
// Low: everything else
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsService = new AnalyticsService();
|
||||
|
||||
@@ -83,6 +83,12 @@ export interface Hotspot {
|
||||
calculatedAt: Date;
|
||||
}
|
||||
|
||||
export type HotspotSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface HotspotWithSeverity extends Hotspot {
|
||||
severity: HotspotSeverity;
|
||||
}
|
||||
|
||||
// DTOs (Data Transfer Objects)
|
||||
export interface CreateReportDTO {
|
||||
photos: File[];
|
||||
|
||||
Reference in New Issue
Block a user