- Fix Prisma client generation and database schema sync issues - Implement user auto-creation from Clerk authentication - Add raw SQL queries to handle MySQL POINT spatial fields - Make password_hash optional for Clerk-only authentication - Update MapPage to fetch real reports from API - Add photo display in map popups with full-size modal view - Configure CORS and Helmet for cross-origin image loading - Add cleanup tracking task to implementation plan Fixes report submission 500 errors and enables end-to-end workflow from submission to map display.
672 lines
20 KiB
TypeScript
672 lines
20 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import L from 'leaflet';
|
|
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, HotspotWithSeverity } from '@/types';
|
|
|
|
// Fix for default marker icons in Leaflet with Vite
|
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
|
|
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
|
L.Icon.Default.mergeOptions({
|
|
iconUrl: markerIcon,
|
|
iconRetinaUrl: markerIcon2x,
|
|
shadowUrl: markerShadow,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
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) {
|
|
const mapRef = useRef<HTMLDivElement>(null);
|
|
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);
|
|
const hotspotsLayerRef = useRef<L.LayerGroup | null>(null);
|
|
|
|
// Initialize map
|
|
useEffect(() => {
|
|
if (!mapRef.current || mapInstanceRef.current) return;
|
|
|
|
// Create map instance
|
|
const map = L.map(mapRef.current).setView(center, zoom);
|
|
|
|
// Add OpenStreetMap tiles
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
// Handle map clicks
|
|
if (onMapClick) {
|
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
|
onMapClick(e.latlng.lat, e.latlng.lng);
|
|
});
|
|
}
|
|
|
|
// 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
|
|
spiderfyOnMaxZoom: true,
|
|
showCoverageOnHover: true,
|
|
zoomToBoundsOnClick: true,
|
|
iconCreateFunction: (cluster) => {
|
|
const count = cluster.getChildCount();
|
|
let sizeClass = 'marker-cluster-small';
|
|
|
|
if (count > 10) {
|
|
sizeClass = 'marker-cluster-large';
|
|
} else if (count > 5) {
|
|
sizeClass = 'marker-cluster-medium';
|
|
}
|
|
|
|
return L.divIcon({
|
|
html: `<div><span>${count}</span></div>`,
|
|
className: `marker-cluster ${sizeClass}`,
|
|
iconSize: L.point(40, 40),
|
|
});
|
|
},
|
|
});
|
|
|
|
mapInstanceRef.current = map;
|
|
|
|
return () => {
|
|
map.remove();
|
|
mapInstanceRef.current = null;
|
|
markersLayerRef.current = null;
|
|
clusterGroupRef.current = null;
|
|
heatLayerRef.current = null;
|
|
hotspotsLayerRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// Filter reports based on filter state
|
|
const getFilteredReports = (): Report[] => {
|
|
if (!filters) return reports;
|
|
|
|
return reports.filter((report) => {
|
|
// Filter by date range
|
|
if (filters.dateRange) {
|
|
const reportDate = new Date(report.createdAt);
|
|
if (filters.dateRange.start && reportDate < filters.dateRange.start) {
|
|
return false;
|
|
}
|
|
if (filters.dateRange.end && reportDate > filters.dateRange.end) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Filter by litter type
|
|
if (filters.litterTypes && filters.litterTypes.length > 0) {
|
|
if (!filters.litterTypes.includes(report.litterType)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Filter by quantity
|
|
if (filters.quantities && filters.quantities.length > 0) {
|
|
if (!filters.quantities.includes(report.quantity)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Filter by verification status
|
|
if (filters.verificationStatus && filters.verificationStatus.length > 0) {
|
|
if (!filters.verificationStatus.includes(report.verificationStatus)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
};
|
|
|
|
// Update markers when reports, filters, or clustering changes
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current || !markersLayerRef.current || !clusterGroupRef.current) return;
|
|
|
|
const map = mapInstanceRef.current;
|
|
const markersLayer = markersLayerRef.current;
|
|
const clusterGroup = clusterGroupRef.current;
|
|
|
|
// Remove existing layers from map
|
|
if (map.hasLayer(markersLayer)) {
|
|
map.removeLayer(markersLayer);
|
|
}
|
|
if (map.hasLayer(clusterGroup)) {
|
|
map.removeLayer(clusterGroup);
|
|
}
|
|
|
|
// Clear existing markers
|
|
markersLayer.clearLayers();
|
|
clusterGroup.clearLayers();
|
|
|
|
// Get filtered reports
|
|
const filteredReports = getFilteredReports();
|
|
|
|
// Add report markers
|
|
filteredReports.forEach((report) => {
|
|
const icon = createCustomMarkerIcon(report.litterType, report.quantity);
|
|
const marker = L.marker([report.latitude, report.longitude], { icon });
|
|
|
|
// Add popup with report details
|
|
const photoUrl = report.photoUrls && report.photoUrls.length > 0 ? report.photoUrls[0] : null;
|
|
// Remove /api from the base URL for uploads since they're served at the root level
|
|
const serverBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api').replace('/api', '');
|
|
const fullPhotoUrl = photoUrl ? (photoUrl.startsWith('http') ? photoUrl : `${serverBaseUrl}${photoUrl}`) : null;
|
|
|
|
marker.bindPopup(`
|
|
<div class="p-2 max-w-xs">
|
|
${fullPhotoUrl ? `
|
|
<img
|
|
src="${fullPhotoUrl}"
|
|
alt="Litter photo"
|
|
class="w-full h-32 object-cover rounded mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
|
onclick="window.openImageModal('${fullPhotoUrl}')"
|
|
onerror="this.style.display='none'"
|
|
/>
|
|
` : ''}
|
|
<h3 class="font-bold text-sm mb-1">${getLitterTypeLabel(report.litterType)}</h3>
|
|
<p class="text-xs text-gray-600">Quantity: ${report.quantity}</p>
|
|
<p class="text-xs text-gray-600">Status: ${report.verificationStatus}</p>
|
|
<p class="text-xs text-gray-600">Date: ${new Date(report.createdAt).toLocaleDateString()}</p>
|
|
${report.description ? `<p class="text-xs mt-1">${report.description}</p>` : ''}
|
|
</div>
|
|
`);
|
|
|
|
// Handle marker click
|
|
if (onMarkerClick) {
|
|
marker.on('click', () => {
|
|
onMarkerClick(report.id);
|
|
});
|
|
}
|
|
|
|
// Add to appropriate layer
|
|
if (showClusters) {
|
|
clusterGroup.addLayer(marker);
|
|
} else {
|
|
markersLayer.addLayer(marker);
|
|
}
|
|
});
|
|
|
|
// Add event markers (always to regular layer, not clustered)
|
|
events.forEach((event) => {
|
|
const icon = createEventMarkerIcon(event.status);
|
|
const marker = L.marker([event.latitude, event.longitude], { icon });
|
|
|
|
marker.bindPopup(`
|
|
<div class="p-2">
|
|
<h3 class="font-bold text-sm mb-1">${event.title}</h3>
|
|
<p class="text-xs text-gray-600">${event.locationName}</p>
|
|
<p class="text-xs text-gray-600">Participants: ${event.participantCount}</p>
|
|
<p class="text-xs text-gray-600">Status: ${event.status}</p>
|
|
</div>
|
|
`);
|
|
|
|
markersLayer.addLayer(marker);
|
|
});
|
|
|
|
// Add appropriate layer to map
|
|
if (showClusters) {
|
|
clusterGroup.addTo(map);
|
|
}
|
|
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]);
|
|
|
|
// 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) {
|
|
alert('Geolocation is not supported by your browser');
|
|
return;
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
const coords: [number, number] = [
|
|
position.coords.latitude,
|
|
position.coords.longitude,
|
|
];
|
|
|
|
if (mapInstanceRef.current) {
|
|
mapInstanceRef.current.setView(coords, 13);
|
|
|
|
// Add user location marker
|
|
L.marker(coords, {
|
|
icon: L.divIcon({
|
|
className: 'user-location-marker',
|
|
html: '<div class="w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg"></div>',
|
|
iconSize: [16, 16],
|
|
}),
|
|
}).addTo(mapInstanceRef.current);
|
|
}
|
|
},
|
|
(error) => {
|
|
alert(`Error getting location: ${error.message}`);
|
|
},
|
|
{
|
|
enableHighAccuracy: true,
|
|
timeout: 10000,
|
|
maximumAge: 0,
|
|
}
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-full h-full">
|
|
<div ref={mapRef} className="w-full h-full rounded-lg" />
|
|
|
|
{/* Geolocation button */}
|
|
<button
|
|
onClick={handleGeolocation}
|
|
className="absolute top-4 right-4 z-[1000] bg-white hover:bg-gray-50 text-gray-700 p-3 rounded-lg shadow-lg transition-colors"
|
|
title="Center map on your location"
|
|
aria-label="Center map on your location"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-5 w-5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Helper function to create custom marker icons based on litter type
|
|
function createCustomMarkerIcon(litterType: string, quantity: string): L.DivIcon {
|
|
const colors = {
|
|
plastic: '#3B82F6', // blue
|
|
metal: '#6B7280', // gray
|
|
glass: '#10B981', // green
|
|
organic: '#84CC16', // lime
|
|
hazardous: '#EF4444', // red
|
|
other: '#8B5CF6', // purple
|
|
};
|
|
|
|
const sizes = {
|
|
minimal: 24,
|
|
moderate: 28,
|
|
significant: 32,
|
|
severe: 36,
|
|
};
|
|
|
|
const color = colors[litterType as keyof typeof colors] || colors.other;
|
|
const size = sizes[quantity as keyof typeof sizes] || sizes.moderate;
|
|
|
|
return L.divIcon({
|
|
className: 'custom-marker',
|
|
html: `
|
|
<div style="
|
|
width: ${size}px;
|
|
height: ${size}px;
|
|
background-color: ${color};
|
|
border: 2px solid white;
|
|
border-radius: 50% 50% 50% 0;
|
|
transform: rotate(-45deg);
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
">
|
|
<div style="
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transform: rotate(45deg);
|
|
color: white;
|
|
font-size: ${size * 0.4}px;
|
|
font-weight: bold;
|
|
">
|
|
${getLitterTypeIcon(litterType)}
|
|
</div>
|
|
</div>
|
|
`,
|
|
iconSize: [size, size],
|
|
iconAnchor: [size / 2, size],
|
|
});
|
|
}
|
|
|
|
// Helper function to create event marker icons
|
|
function createEventMarkerIcon(status: string): L.DivIcon {
|
|
const colors = {
|
|
upcoming: '#F59E0B', // amber
|
|
completed: '#10B981', // green
|
|
cancelled: '#6B7280', // gray
|
|
};
|
|
|
|
const color = colors[status as keyof typeof colors] || colors.upcoming;
|
|
|
|
return L.divIcon({
|
|
className: 'event-marker',
|
|
html: `
|
|
<div style="
|
|
width: 32px;
|
|
height: 32px;
|
|
background-color: ${color};
|
|
border: 3px solid white;
|
|
border-radius: 50%;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
">
|
|
📅
|
|
</div>
|
|
`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
});
|
|
}
|
|
|
|
// Helper function to get litter type icon
|
|
function getLitterTypeIcon(litterType: string): string {
|
|
const icons: Record<string, string> = {
|
|
plastic: '♻',
|
|
metal: '⚙',
|
|
glass: '◆',
|
|
organic: '🌿',
|
|
hazardous: '⚠',
|
|
other: '●',
|
|
};
|
|
const icon = icons[litterType];
|
|
return icon !== undefined ? icon : '●';
|
|
}
|
|
|
|
// Helper function to get litter type label
|
|
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';
|
|
}
|