Compare commits

...

2 Commits

Author SHA1 Message Date
9aa6507e7e fix: expand map filters by default to show hotspot toggle
Make the filters panel expanded by default so users can immediately
see and access the hotspot visualization toggle without needing to
click to expand the panel first.
2025-11-16 19:03:28 +00:00
40a96142ba 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
2025-11-16 19:01:26 +00:00
6 changed files with 372 additions and 7 deletions

View File

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

View File

@@ -8,6 +8,9 @@ interface MapFiltersProps {
showClusters: boolean;
onToggleHeatmap: () => void;
showHeatmap: boolean;
onToggleHotspots?: () => void;
showHotspots?: boolean;
isLoadingHotspots?: boolean;
}
export function MapFilters({
@@ -17,8 +20,11 @@ export function MapFilters({
showClusters,
onToggleHeatmap,
showHeatmap,
onToggleHotspots,
showHotspots = false,
isLoadingHotspots = false,
}: MapFiltersProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(true);
const litterTypes: LitterType[] = ['plastic', 'metal', 'glass', 'organic', 'hazardous', 'other'];
const quantities: QuantityLevel[] = ['minimal', 'moderate', 'significant', 'severe'];
@@ -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';
}

View File

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

View File

@@ -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();

View File

@@ -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[];