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:
@@ -32,7 +32,7 @@
|
||||
- Implement filter state management and map updates
|
||||
- _Requirements: 2.4, 2.5_
|
||||
|
||||
- [ ] 5. Create heat map visualization
|
||||
- [-] 5. Create heat map visualization
|
||||
- Integrate Leaflet.heat plugin
|
||||
- Implement heat map toggle control
|
||||
- Configure heat map intensity based on report density
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export function MapPage() {
|
||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
const [showClusters, setShowClusters] = useState(true);
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
|
||||
// Sample data for demonstration (will be replaced with API data in later tasks)
|
||||
const sampleReports: Report[] = [
|
||||
@@ -119,6 +120,10 @@ export function MapPage() {
|
||||
setShowClusters(!showClusters);
|
||||
};
|
||||
|
||||
const handleToggleHeatmap = () => {
|
||||
setShowHeatmap(!showHeatmap);
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -134,6 +139,7 @@ export function MapPage() {
|
||||
reports={sampleReports}
|
||||
filters={filters}
|
||||
showClusters={showClusters}
|
||||
showHeatmap={showHeatmap}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
/>
|
||||
@@ -143,6 +149,8 @@ export function MapPage() {
|
||||
onFiltersChange={setFilters}
|
||||
onToggleClusters={handleToggleClusters}
|
||||
showClusters={showClusters}
|
||||
onToggleHeatmap={handleToggleHeatmap}
|
||||
showHeatmap={showHeatmap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
24
src/types/leaflet-heat.d.ts
vendored
Normal file
24
src/types/leaflet-heat.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user