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:
2025-11-16 16:05:31 +00:00
parent ba324b370e
commit 8f297e1ec4
5 changed files with 114 additions and 1 deletions

View File

@@ -32,7 +32,7 @@
- Implement filter state management and map updates - Implement filter state management and map updates
- _Requirements: 2.4, 2.5_ - _Requirements: 2.4, 2.5_
- [ ] 5. Create heat map visualization - [-] 5. Create heat map visualization
- Integrate Leaflet.heat plugin - Integrate Leaflet.heat plugin
- Implement heat map toggle control - Implement heat map toggle control
- Configure heat map intensity based on report density - Configure heat map intensity based on report density

View File

@@ -6,6 +6,8 @@ interface MapFiltersProps {
onFiltersChange: (filters: FilterState) => void; onFiltersChange: (filters: FilterState) => void;
onToggleClusters: () => void; onToggleClusters: () => void;
showClusters: boolean; showClusters: boolean;
onToggleHeatmap: () => void;
showHeatmap: boolean;
} }
export function MapFilters({ export function MapFilters({
@@ -13,6 +15,8 @@ export function MapFilters({
onFiltersChange, onFiltersChange,
onToggleClusters, onToggleClusters,
showClusters, showClusters,
onToggleHeatmap,
showHeatmap,
}: MapFiltersProps) { }: MapFiltersProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@@ -127,6 +131,28 @@ export function MapFilters({
</button> </button>
</div> </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 */} {/* 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

@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster/dist/MarkerCluster.css'; 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 type { Report, CleanupEvent, FilterState } from '@/types'; import type { Report, CleanupEvent, FilterState } from '@/types';
// Fix for default marker icons in Leaflet with Vite // Fix for default marker icons in Leaflet with Vite
@@ -37,6 +38,7 @@ export function MapView({
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
showClusters = true, showClusters = true,
showHeatmap = false,
center = [53.3498, -6.2603], // Dublin, Ireland default center = [53.3498, -6.2603], // Dublin, Ireland default
zoom = 7, zoom = 7,
}: MapViewProps) { }: MapViewProps) {
@@ -44,6 +46,7 @@ export function MapView({
const mapInstanceRef = useRef<L.Map | null>(null); const mapInstanceRef = useRef<L.Map | null>(null);
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);
// Initialize map // Initialize map
useEffect(() => { useEffect(() => {
@@ -99,6 +102,7 @@ export function MapView({
mapInstanceRef.current = null; mapInstanceRef.current = null;
markersLayerRef.current = null; markersLayerRef.current = null;
clusterGroupRef.current = null; clusterGroupRef.current = null;
heatLayerRef.current = null;
}; };
}, []); }, []);
@@ -221,6 +225,57 @@ export function MapView({
markersLayer.addTo(map); markersLayer.addTo(map);
}, [reports, events, filters, showClusters, onMarkerClick]); }, [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 // Handle geolocation
const handleGeolocation = () => { const handleGeolocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {

View File

@@ -9,6 +9,7 @@ export function MapPage() {
const [selectedReportId, setSelectedReportId] = useState<string | null>(null); const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
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);
// 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[] = [
@@ -119,6 +120,10 @@ export function MapPage() {
setShowClusters(!showClusters); setShowClusters(!showClusters);
}; };
const handleToggleHeatmap = () => {
setShowHeatmap(!showHeatmap);
};
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">
@@ -134,6 +139,7 @@ export function MapPage() {
reports={sampleReports} reports={sampleReports}
filters={filters} filters={filters}
showClusters={showClusters} showClusters={showClusters}
showHeatmap={showHeatmap}
onMarkerClick={handleMarkerClick} onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick} onMapClick={handleMapClick}
/> />
@@ -143,6 +149,8 @@ export function MapPage() {
onFiltersChange={setFilters} onFiltersChange={setFilters}
onToggleClusters={handleToggleClusters} onToggleClusters={handleToggleClusters}
showClusters={showClusters} showClusters={showClusters}
onToggleHeatmap={handleToggleHeatmap}
showHeatmap={showHeatmap}
/> />
</div> </div>
</div> </div>

24
src/types/leaflet-heat.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
// Type declarations for leaflet.heat plugin
import * as L from 'leaflet';
declare module 'leaflet' {
interface HeatLayerOptions {
minOpacity?: number;
maxZoom?: number;
max?: number;
radius?: number;
blur?: number;
gradient?: { [key: number]: string };
}
interface HeatLayer extends Layer {
setLatLngs(latlngs: Array<[number, number, number]>): this;
addLatLng(latlng: [number, number, number]): this;
setOptions(options: HeatLayerOptions): this;
}
function heatLayer(
latlngs: Array<[number, number, number]>,
options?: HeatLayerOptions
): HeatLayer;
}