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
|
- 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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
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