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

@@ -83,7 +83,7 @@
- Create export to CSV functionality - Create export to CSV functionality
- _Requirements: 5.1, 5.5_ - _Requirements: 5.1, 5.5_
- [x] 11. Implement data visualization charts - [ ] 11. Implement data visualization charts
- Create time series chart for report trends - Create time series chart for report trends
- Build pie chart for litter type distribution - Build pie chart for litter type distribution
- Implement comparison metrics with previous periods - Implement comparison metrics with previous periods
@@ -91,7 +91,7 @@
- Configure chart responsiveness for mobile - Configure chart responsiveness for mobile
- _Requirements: 5.2, 5.3, 5.4_ - _Requirements: 5.2, 5.3, 5.4_
- [ ] 12. Create hotspot visualization - [-] 12. Create hotspot visualization
- Fetch and display hotspot data on map - Fetch and display hotspot data on map
- Implement color-coded severity indicators - Implement color-coded severity indicators
- Show hotspot boundaries and statistics - Show hotspot boundaries and statistics

View File

@@ -8,6 +8,9 @@ interface MapFiltersProps {
showClusters: boolean; showClusters: boolean;
onToggleHeatmap: () => void; onToggleHeatmap: () => void;
showHeatmap: boolean; showHeatmap: boolean;
onToggleHotspots?: () => void;
showHotspots?: boolean;
isLoadingHotspots?: boolean;
} }
export function MapFilters({ export function MapFilters({
@@ -17,6 +20,9 @@ export function MapFilters({
showClusters, showClusters,
onToggleHeatmap, onToggleHeatmap,
showHeatmap, showHeatmap,
onToggleHotspots,
showHotspots = false,
isLoadingHotspots = false,
}: MapFiltersProps) { }: MapFiltersProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@@ -153,6 +159,62 @@ export function MapFilters({
</button> </button>
</div> </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 */} {/* Date Range */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <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/dist/MarkerCluster.Default.css';
import 'leaflet.markercluster'; import 'leaflet.markercluster';
import 'leaflet.heat'; 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 // Fix for default marker icons in Leaflet with Vite
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
@@ -22,11 +22,13 @@ L.Icon.Default.mergeOptions({
interface MapViewProps { interface MapViewProps {
reports: Report[]; reports: Report[];
events?: CleanupEvent[]; events?: CleanupEvent[];
hotspots?: HotspotWithSeverity[];
filters?: FilterState; filters?: FilterState;
onMarkerClick?: (reportId: string) => void; onMarkerClick?: (reportId: string) => void;
onMapClick?: (lat: number, lng: number) => void; onMapClick?: (lat: number, lng: number) => void;
showHeatmap?: boolean; showHeatmap?: boolean;
showClusters?: boolean; showClusters?: boolean;
showHotspots?: boolean;
center?: [number, number]; center?: [number, number];
zoom?: number; zoom?: number;
} }
@@ -34,11 +36,13 @@ interface MapViewProps {
export function MapView({ export function MapView({
reports, reports,
events = [], events = [],
hotspots = [],
filters, filters,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
showClusters = true, showClusters = true,
showHeatmap = false, showHeatmap = false,
showHotspots = false,
center = [53.3498, -6.2603], // Dublin, Ireland default center = [53.3498, -6.2603], // Dublin, Ireland default
zoom = 7, zoom = 7,
}: MapViewProps) { }: MapViewProps) {
@@ -47,6 +51,7 @@ export function MapView({
const markersLayerRef = useRef<L.LayerGroup | null>(null); const markersLayerRef = useRef<L.LayerGroup | null>(null);
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null); const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null);
const heatLayerRef = useRef<L.HeatLayer | null>(null); const heatLayerRef = useRef<L.HeatLayer | null>(null);
const hotspotsLayerRef = useRef<L.LayerGroup | null>(null);
// Initialize map // Initialize map
useEffect(() => { useEffect(() => {
@@ -71,6 +76,9 @@ export function MapView({
// Create markers layer // Create markers layer
markersLayerRef.current = L.layerGroup(); markersLayerRef.current = L.layerGroup();
// Create hotspots layer
hotspotsLayerRef.current = L.layerGroup();
// Create cluster group with custom configuration // Create cluster group with custom configuration
clusterGroupRef.current = L.markerClusterGroup({ clusterGroupRef.current = L.markerClusterGroup({
maxClusterRadius: 50, // Cluster markers within 50 pixels maxClusterRadius: 50, // Cluster markers within 50 pixels
@@ -103,6 +111,7 @@ export function MapView({
markersLayerRef.current = null; markersLayerRef.current = null;
clusterGroupRef.current = null; clusterGroupRef.current = null;
heatLayerRef.current = null; heatLayerRef.current = null;
hotspotsLayerRef.current = null;
}; };
}, []); }, []);
@@ -276,6 +285,87 @@ export function MapView({
} }
}, [reports, filters, showHeatmap]); }, [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 // Handle geolocation
const handleGeolocation = () => { const handleGeolocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
@@ -457,3 +547,111 @@ function getLitterTypeLabel(litterType: string): string {
const label = litterType.charAt(0).toUpperCase() + litterType.slice(1); const label = litterType.charAt(0).toUpperCase() + litterType.slice(1);
return label; 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';
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { MapView, MapFilters } from '@/components/map'; 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 * Map page - Interactive map displaying litter reports and cleanup events
@@ -10,6 +11,9 @@ export function MapPage() {
const [filters, setFilters] = useState<FilterState>({}); const [filters, setFilters] = useState<FilterState>({});
const [showClusters, setShowClusters] = useState(true); const [showClusters, setShowClusters] = useState(true);
const [showHeatmap, setShowHeatmap] = useState(false); 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) // Sample data for demonstration (will be replaced with API data in later tasks)
const sampleReports: Report[] = [ const sampleReports: Report[] = [
@@ -124,6 +128,63 @@ export function MapPage() {
setShowHeatmap(!showHeatmap); 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 ( return (
<div className="h-screen flex flex-col"> <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"> <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"> <div className="h-full bg-white rounded-lg shadow-lg overflow-hidden relative">
<MapView <MapView
reports={sampleReports} reports={sampleReports}
hotspots={hotspots}
filters={filters} filters={filters}
showClusters={showClusters} showClusters={showClusters}
showHeatmap={showHeatmap} showHeatmap={showHeatmap}
showHotspots={showHotspots}
onMarkerClick={handleMarkerClick} onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick} onMapClick={handleMapClick}
/> />
@@ -151,6 +214,9 @@ export function MapPage() {
showClusters={showClusters} showClusters={showClusters}
onToggleHeatmap={handleToggleHeatmap} onToggleHeatmap={handleToggleHeatmap}
showHeatmap={showHeatmap} showHeatmap={showHeatmap}
onToggleHotspots={handleToggleHotspots}
showHotspots={showHotspots}
isLoadingHotspots={isLoadingHotspots}
/> />
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
// Analytics service for API interactions // Analytics service for API interactions
import { apiClient } from './api'; 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 { class AnalyticsService {
/** /**
@@ -91,6 +91,39 @@ class AnalyticsService {
return response.blob(); 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(); export const analyticsService = new AnalyticsService();

View File

@@ -83,6 +83,12 @@ export interface Hotspot {
calculatedAt: Date; calculatedAt: Date;
} }
export type HotspotSeverity = 'low' | 'medium' | 'high' | 'critical';
export interface HotspotWithSeverity extends Hotspot {
severity: HotspotSeverity;
}
// DTOs (Data Transfer Objects) // DTOs (Data Transfer Objects)
export interface CreateReportDTO { export interface CreateReportDTO {
photos: File[]; photos: File[];