feat: implement heat map visualization with Leaflet.heat plugin
- Integrate Leaflet.heat plugin for heat map overlay - Add heat map toggle control in MapFilters component - Configure heat map intensity based on report quantity levels (minimal: 0.3, moderate: 0.5, significant: 0.7, severe: 1.0) - Implement heat map layer management with proper cleanup - Add custom gradient colors (blue to red) for visual intensity representation - Configure heat map with radius: 25px, blur: 15, maxZoom: 17 - Add TypeScript type declarations for leaflet.heat - Heat map respects existing filter state (date range, litter type, quantity) Implements task 5: Create heat map visualization Requirements: 3.1, 3.5
This commit is contained in:
@@ -6,6 +6,8 @@ interface MapFiltersProps {
|
||||
onFiltersChange: (filters: FilterState) => void;
|
||||
onToggleClusters: () => void;
|
||||
showClusters: boolean;
|
||||
onToggleHeatmap: () => void;
|
||||
showHeatmap: boolean;
|
||||
}
|
||||
|
||||
export function MapFilters({
|
||||
@@ -13,6 +15,8 @@ export function MapFilters({
|
||||
onFiltersChange,
|
||||
onToggleClusters,
|
||||
showClusters,
|
||||
onToggleHeatmap,
|
||||
showHeatmap,
|
||||
}: MapFiltersProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -127,6 +131,28 @@ export function MapFilters({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Heat Map Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="heatmap-toggle" className="text-sm font-medium text-gray-700">
|
||||
Heat Map
|
||||
</label>
|
||||
<button
|
||||
id="heatmap-toggle"
|
||||
onClick={onToggleHeatmap}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
showHeatmap ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={showHeatmap}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showHeatmap ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
|
||||
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';
|
||||
|
||||
// Fix for default marker icons in Leaflet with Vite
|
||||
@@ -37,6 +38,7 @@ export function MapView({
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
showClusters = true,
|
||||
showHeatmap = false,
|
||||
center = [53.3498, -6.2603], // Dublin, Ireland default
|
||||
zoom = 7,
|
||||
}: MapViewProps) {
|
||||
@@ -44,6 +46,7 @@ export function MapView({
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const markersLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null);
|
||||
const heatLayerRef = useRef<L.HeatLayer | null>(null);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
@@ -99,6 +102,7 @@ export function MapView({
|
||||
mapInstanceRef.current = null;
|
||||
markersLayerRef.current = null;
|
||||
clusterGroupRef.current = null;
|
||||
heatLayerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -221,6 +225,57 @@ export function MapView({
|
||||
markersLayer.addTo(map);
|
||||
}, [reports, events, filters, showClusters, onMarkerClick]);
|
||||
|
||||
// Update heat map when reports, filters, or showHeatmap changes
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
const map = mapInstanceRef.current;
|
||||
|
||||
// Remove existing heat layer if it exists
|
||||
if (heatLayerRef.current && map.hasLayer(heatLayerRef.current)) {
|
||||
map.removeLayer(heatLayerRef.current);
|
||||
heatLayerRef.current = null;
|
||||
}
|
||||
|
||||
// Only create heat layer if showHeatmap is true
|
||||
if (showHeatmap) {
|
||||
const filteredReports = getFilteredReports();
|
||||
|
||||
// Convert reports to heat map data points
|
||||
// Format: [latitude, longitude, intensity]
|
||||
const heatData: [number, number, number][] = filteredReports.map((report) => {
|
||||
// Calculate intensity based on quantity level
|
||||
const intensityMap = {
|
||||
minimal: 0.3,
|
||||
moderate: 0.5,
|
||||
significant: 0.7,
|
||||
severe: 1.0,
|
||||
};
|
||||
const intensity = intensityMap[report.quantity] || 0.5;
|
||||
|
||||
return [report.latitude, report.longitude, intensity];
|
||||
});
|
||||
|
||||
// Create heat layer with configuration
|
||||
if (heatData.length > 0) {
|
||||
heatLayerRef.current = L.heatLayer(heatData, {
|
||||
radius: 25, // Radius of each heat point in pixels
|
||||
blur: 15, // Amount of blur
|
||||
maxZoom: 17, // Maximum zoom level for heat layer
|
||||
max: 1.0, // Maximum intensity value
|
||||
gradient: {
|
||||
0.0: '#0000ff', // Blue for low intensity
|
||||
0.3: '#00ffff', // Cyan
|
||||
0.5: '#00ff00', // Green
|
||||
0.7: '#ffff00', // Yellow
|
||||
0.9: '#ff8800', // Orange
|
||||
1.0: '#ff0000', // Red for high intensity
|
||||
},
|
||||
}).addTo(map);
|
||||
}
|
||||
}
|
||||
}, [reports, filters, showHeatmap]);
|
||||
|
||||
// Handle geolocation
|
||||
const handleGeolocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
|
||||
Reference in New Issue
Block a user