import React, { useEffect, useState, useMemo, useRef } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, useMapEvents, CircleMarker } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { AlertTriangle, Construction, Crosshair, MapPin, Navigation, Ruler, Wind, Compass, RotateCw, Trash2, MousePointer2 } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import FloatingSearchBar from './FloatingSearchBar'; import { MapLayersMenu } from './MapLayersMenu'; function AirQualityMarkers({ center, zoom }: { center: [number, number], zoom: number }) { const [aqData, setAqData] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (zoom < 10) return; const fetchAQData = async () => { setLoading(true); try { const bbox = [ center[1] - 2, center[0] - 2, center[1] + 2, center[0] + 2 ].join(','); const response = await fetch( `https://api.openaq.org/v2/measurements?city=&country=&location=¶meter=pm25&limit=20&offset=0&sort=desc&radius=100&bbox=${bbox}`, { headers: { 'Accept': 'application/json' } } ); const data = await response.json(); if (data.results) { const uniqueLocations = new Map(); data.results.forEach((m: any) => { const key = `${m.coordinates.latitude},${m.coordinates.longitude}`; if (!uniqueLocations.has(key)) { uniqueLocations.set(key, { lat: m.coordinates.latitude, lon: m.coordinates.longitude, value: m.value, parameter: m.parameter, unit: m.unit, city: m.city, location: m.location }); } }); setAqData(Array.from(uniqueLocations.values())); } } catch (error) { console.log('AQ API not available, using mock data'); setAqData([ { lat: center[1] + 0.02, lon: center[0] + 0.02, value: 35, city: 'Demo City A', location: 'Station A' }, { lat: center[1] - 0.015, lon: center[0] + 0.01, value: 78, city: 'Demo City B', location: 'Station B' }, { lat: center[1] + 0.01, lon: center[0] - 0.015, value: 150, city: 'Demo City C', location: 'Station C' }, ]); } finally { setLoading(false); } }; fetchAQData(); }, [center, zoom]); const getAQColor = (value: number) => { if (value <= 50) return '#22c55e'; if (value <= 100) return '#eab308'; if (value <= 150) return '#f97316'; if (value <= 200) return '#ef4444'; if (value <= 300) return '#a855f7'; return '#7c3aed'; }; const getAQLevel = (value: number) => { if (value <= 50) return 'Good'; if (value <= 100) return 'Moderate'; if (value <= 150) return 'Unhealthy for Sensitive'; if (value <= 200) return 'Unhealthy'; if (value <= 300) return 'Very Unhealthy'; return 'Hazardous'; }; if (loading || aqData.length === 0) return null; return ( <> {aqData.map((station, idx) => (
{station.value.toFixed(0)} {station.unit}

{station.location}

{station.city}

{getAQLevel(station.value)}

))} ); } function MapEvents({ onContextMenu, onClick, activeLayersRef, measurePointsRef, setMeasurePoints, setRotation }: { onContextMenu: (e: L.LeafletMouseEvent) => void, onClick: (e: L.LeafletMouseEvent) => void, activeLayersRef: React.MutableRefObject, measurePointsRef: React.MutableRefObject<[number, number][]>, setMeasurePoints: (points: [number, number][]) => void, setRotation: (rotation: number) => void }) { const map = useMap(); useEffect(() => { map.doubleClickZoom.disable(); map.keyboard.disable(); let isRotating = false; let startX = 0; let startRotation = 0; const mapPane = map.getPanes().mapPane; const updateRotation = (bearing: number) => { if (mapPane) { mapPane.style.transform = `rotate(${bearing}deg)`; } setRotation(bearing); }; const handleMouseDown = (e: L.LeafletMouseEvent) => { if (e.originalEvent.shiftKey || e.originalEvent.button === 2) { isRotating = true; startX = e.originalEvent.clientX; startRotation = (map as any).getBearing?.() || 0; map.dragging.disable(); map.getContainer().style.cursor = 'grab'; } }; const handleMouseMove = (e: L.LeafletMouseEvent) => { if (isRotating) { const deltaX = e.originalEvent.clientX - startX; const newBearing = startRotation + deltaX * 0.5; (map as any).setBearing?.(newBearing); updateRotation(newBearing); map.getContainer().style.cursor = 'grabbing'; } }; const handleMouseUp = () => { if (isRotating) { isRotating = false; map.dragging.enable(); map.getContainer().style.cursor = ''; } }; let lastTouchDistance = 0; let lastTouchAngle = 0; let touchStartBearing = 0; const handleTouchStart = (e: any) => { if (e.touches?.length === 2) { e.preventDefault?.(); const t1 = e.touches[0]; const t2 = e.touches[1]; const dx = t2.clientX - t1.clientX; const dy = t2.clientY - t1.clientY; lastTouchDistance = Math.sqrt(dx * dx + dy * dy); lastTouchAngle = Math.atan2(dy, dx) * 180 / Math.PI; touchStartBearing = (map as any).getBearing?.() || 0; isRotating = true; map.dragging.disable(); } }; const handleTouchMove = (e: any) => { if (e.touches?.length === 2 && isRotating) { e.preventDefault?.(); const t1 = e.touches[0]; const t2 = e.touches[1]; const dx = t2.clientX - t1.clientX; const dy = t2.clientY - t1.clientY; const distance = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx) * 180 / Math.PI; const angleDelta = angle - lastTouchAngle; const newBearing = touchStartBearing + angleDelta; (map as any).setBearing?.(newBearing); updateRotation(newBearing); lastTouchAngle = angle; } }; const handleTouchEnd = () => { if (isRotating) { isRotating = false; map.dragging.enable(); } }; map.on('mousedown', handleMouseDown); map.on('mousemove', handleMouseMove); map.on('mouseup', handleMouseUp); map.on('touchstart', handleTouchStart); map.on('touchmove', handleTouchMove); map.on('touchend', handleTouchEnd); map.on('dblclick', (e: L.LeafletMouseEvent) => { e.originalEvent.preventDefault(); e.latlng && map.flyTo(e.latlng, map.getZoom() + 1, { duration: 0.5 }); }); return () => { map.off('mousedown', handleMouseDown); map.off('mousemove', handleMouseMove); map.off('mouseup', handleMouseUp); map.off('touchstart', handleTouchStart); map.off('touchmove', handleTouchMove); map.off('touchend', handleTouchEnd); map.doubleClickZoom.enable(); map.keyboard.enable(); }; }, [map, setRotation]); useMapEvents({ contextmenu: (e) => { if (!activeLayersRef.current.includes('measure')) { e.originalEvent.preventDefault(); onContextMenu(e); } }, click: (e) => { if (activeLayersRef.current.includes('measure')) { setMeasurePoints([...measurePointsRef.current, [e.latlng.lng, e.latlng.lat]]); } else { onClick(e); } } }); return null; } function FlyToLocation({ coords, mapRef }: { coords: [number, number] | null, mapRef: React.RefObject }) { const map = useMap(); useEffect(() => { if (coords && mapRef.current) { const currentZoom = mapRef.current.getZoom(); const targetZoom = Math.min(Math.max(currentZoom, 12), 15); map.flyTo([coords[1], coords[0]], targetZoom, { duration: 1.5, easeLinearity: 0.25 }); } }, [coords, map, mapRef]); return null; } function FitRouteBounds({ positions }: { positions: [number, number][] }) { const map = useMap(); useEffect(() => { if (positions.length < 2) return; const bounds = L.latLngBounds(positions.map(([lat, lng]) => [lat, lng])); map.fitBounds(bounds, { padding: [50, 50], duration: 1.5, maxZoom: 14 }); }, [positions, map]); return null; } function RoutePlayback({ positions, progress, showPlayback }: { positions: [number, number][], progress: number, showPlayback: boolean }) { const map = useMap(); useEffect(() => { if (!positions.length) return; map.eachLayer((layer) => { if ((layer as any).options?.pane === 'playbackPane') { map.removeLayer(layer); } }); if (!showPlayback) return; const playbackPane = map.createPane('playbackPane'); if (playbackPane) { (playbackPane as any).style.zIndex = '450'; } const progressLength = Math.floor(positions.length * progress); const visiblePositions = positions.slice(0, Math.max(1, progressLength)); const currentPosition = positions[Math.max(0, progressLength - 1)]; if (visiblePositions.length > 1) { L.polyline(visiblePositions, { color: '#10b981', weight: 6, opacity: 0.9, pane: 'playbackPane' }).addTo(map); L.polyline(positions.slice(progressLength), { color: '#94a3b8', weight: 6, opacity: 0.4, dashArray: '10, 10', pane: 'playbackPane' }).addTo(map); } if (currentPosition) { const playheadIcon = L.divIcon({ html: `
`, className: 'playhead-marker', iconSize: [32, 32], iconAnchor: [16, 16] }); L.marker([currentPosition[0], currentPosition[1]], { icon: playheadIcon, pane: 'playbackPane' }).addTo(map); } return () => { map.eachLayer((layer) => { if ((layer as any).options?.pane === 'playbackPane') { map.removeLayer(layer); } }); }; }, [positions, progress, showPlayback, map]); return null; } function RiskPolyline({ positions, riskData, showPlayback }: { positions: [number, number][], riskData: any[], showPlayback?: boolean }) { const map = useMap(); const riskDataRef = useRef(riskData); const positionsRef = useRef(positions); useEffect(() => { riskDataRef.current = riskData; }, [riskData]); useEffect(() => { positionsRef.current = positions; }, [positions]); useEffect(() => { if (!positions.length || !riskData.length) return; if (showPlayback) return; const getColor = (risk: number) => { if (risk < 0.4) return '#10b981'; if (risk < 0.7) return '#f59e0b'; return '#f43f5e'; }; const getWidth = (traffic: number) => { return 4 + traffic * 8; }; const currentRiskData = riskDataRef.current; const currentPositions = positionsRef.current; if (!currentPositions.length || !currentRiskData.length) return; const segments = currentRiskData; segments.forEach((segment: any) => { if (segment.start && segment.end) { const latlngs: L.LatLngExpression[] = [ [segment.start[1], segment.start[0]], [segment.end[1], segment.end[0]] ]; const risk = segment.risk_probability || segment.risk_score || 0; const traffic = segment.traffic_level || 0; L.polyline(latlngs, { color: getColor(risk), weight: getWidth(traffic), opacity: 0.9 }).addTo(map); } }); return () => { map.eachLayer((layer) => { if (layer instanceof L.Polyline && !(layer instanceof L.Polygon)) { map.removeLayer(layer); } }); }; }, [map, showPlayback]); return null; } const carIcon = L.divIcon({ html: `
`, className: 'custom-marker', iconSize: [40, 40], iconAnchor: [20, 20] }); const bikeIcon = L.divIcon({ html: `
`, className: 'custom-marker', iconSize: [40, 40], iconAnchor: [20, 20] }); const startIcon = (vehicleType: string = 'driving') => L.divIcon({ html: `
`, className: 'custom-marker', iconSize: [40, 40], iconAnchor: [20, 20] }); const endIcon = L.divIcon({ html: `
`, className: 'custom-marker', iconSize: [40, 40], iconAnchor: [20, 20] }); const waypointIcon = L.divIcon({ html: `
?
`, className: 'custom-marker', iconSize: [28, 28], iconAnchor: [14, 14] }); const incidentIcon = (type: string, severity: string) => { const isAccident = type.toLowerCase() === 'accident'; const isHigh = severity.toLowerCase() === 'high'; const size = isHigh ? 28 : 22; const color = isAccident ? (isHigh ? 'bg-rose-600' : 'bg-rose-400') : 'bg-amber-500'; return L.divIcon({ html: `
${isAccident ? `` : `` }
`, className: 'custom-marker', iconSize: [size, size], iconAnchor: [size/2, size/2] }); }; interface LeafletMapViewProps { routeData: any; alternativeRoutes?: any[]; riskData: any; previousRouteData?: any; previousRiskData?: any; incidentsData: any; startLoc: [number, number] | null; endLoc: [number, number] | null; setStartLoc: (loc: [number, number] | null) => void; setEndLoc: (loc: [number, number] | null) => void; setStartQuery: (query: string) => void; setEndQuery: (query: string) => void; theme: 'dark' | 'light'; showIncidents: boolean; showRoutePlayback?: boolean; playbackSpeed?: number; mapStyleType?: string; setMapStyleType?: (type: string) => void; activeLayers?: string[]; setActiveLayers?: (layers: string[]) => void; vehicleType?: 'driving' | 'cycling'; waypoints?: Array<{ id: number; query: string; loc: [number, number] | null }>; } export default function LeafletMapView({ routeData, alternativeRoutes, riskData, previousRouteData, previousRiskData, incidentsData, startLoc, endLoc, setStartLoc, setEndLoc, setStartQuery, setEndQuery, theme, showIncidents, showRoutePlayback, playbackSpeed = 1, mapStyleType = 'default', setMapStyleType, activeLayers = [], setActiveLayers, vehicleType = 'driving', waypoints = [] }: LeafletMapViewProps) { const [viewState, setViewState] = useState({ center: [20.5937, 78.9629] as [number, number], zoom: 5 }); useEffect(() => { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { setViewState({ center: [position.coords.latitude, position.coords.longitude], zoom: 12 }); }, () => { console.log('Geolocation not available, using default center'); }, { timeout: 5000 } ); } }, []); const [contextMenu, setContextMenu] = useState<{lat: number, lng: number, x: number, y: number} | null>(null); const [hoveredIncident, setHoveredIncident] = useState(null); const [hoveredSegment, setHoveredSegment] = useState(null); const [poiResults, setPoiResults] = useState([]); const [measurePoints, setMeasurePoints] = useState<[number, number][]>([]); const [progress, setProgress] = useState(1); const [isMeasuring, setIsMeasuring] = useState(false); const [airQualityData, setAirQualityData] = useState(null); const [rotation, setRotation] = useState(0); const mapRef = useRef(null); const activeLayersRef = useRef(activeLayers); const measurePointsRef = useRef(measurePoints); useEffect(() => { activeLayersRef.current = activeLayers; }, [activeLayers]); useEffect(() => { measurePointsRef.current = measurePoints; }, [measurePoints]); const tileLayers = useMemo(() => ({ default: theme === 'dark' ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', terrain: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', cycling: 'https://{s}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png', navigation: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png' }), [theme]); const routePositions = useMemo(() => { if (!routeData || !routeData.geometry || !routeData.geometry.coordinates) return []; return routeData.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]); }, [routeData]); const previousRoutePositions = useMemo(() => { if (!previousRouteData || !previousRouteData.geometry || !previousRouteData.geometry.coordinates) return null; return previousRouteData.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]); }, [previousRouteData]); const alternativeRoutePositions = useMemo(() => { if (!alternativeRoutes || alternativeRoutes.length === 0) return []; return alternativeRoutes .filter((r: any) => r && r.geometry && r.geometry.coordinates) .map((route: any) => route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number])); }, [alternativeRoutes]); const measureDistance = useMemo(() => { if (measurePoints.length < 2) return 0; let dist = 0; for (let i = 1; i < measurePoints.length; i++) { const p1 = measurePoints[i - 1]; const p2 = measurePoints[i]; const R = 6371e3; const φ1 = p1[1] * Math.PI/180; const φ2 = p2[1] * Math.PI/180; const Δφ = (p2[1]-p1[1]) * Math.PI/180; const Δλ = (p2[0]-p1[0]) * Math.PI/180; const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) * Math.sin(Δλ/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); dist += R * c; } return dist; }, [measurePoints]); useEffect(() => { if (startLoc) { setViewState(prev => ({ ...prev, center: [startLoc[1], startLoc[0]] })); } }, [startLoc]); useEffect(() => { if (!riskData || riskData.length === 0) { setProgress(1); return; } setProgress(0); let start: number; let animationFrameId: number; const baseDuration = showRoutePlayback ? 10000 : 2000; const duration = baseDuration / playbackSpeed; const animate = (timestamp: number) => { if (!start) start = timestamp; const elapsed = timestamp - start; const currentProgress = Math.min(elapsed / duration, 1); setProgress(currentProgress); if (currentProgress < 1) { animationFrameId = requestAnimationFrame(animate); } else if (showRoutePlayback) { start = timestamp; animationFrameId = requestAnimationFrame(animate); } }; animationFrameId = requestAnimationFrame(animate); return () => { if (animationFrameId) { cancelAnimationFrame(animationFrameId); } }; }, [riskData, showRoutePlayback, playbackSpeed]); const handleContextMenu = (e: L.LeafletMouseEvent) => { setContextMenu({ lng: e.latlng.lng, lat: e.latlng.lat, x: e.containerPoint.x, y: e.containerPoint.y }); }; const handleClick = () => { setContextMenu(null); setPoiResults([]); }; const currentTileLayer = tileLayers[mapStyleType as keyof typeof tileLayers] || tileLayers.default; const getRiskColor = (risk: number) => { if (risk < 0.4) return '#10b981'; if (risk < 0.7) return '#f59e0b'; return '#f43f5e'; }; const getRiskWeight = (traffic: number) => { return 4 + traffic * 8; }; return (
{activeLayers.includes('cycling') && ( )} {activeLayers.includes('terrain') && ( )} {activeLayers.includes('traffic') && ( )} {activeLayers.includes('airquality') && ( <> )} {activeLayers.includes('labels') && ( )} {activeLayers.includes('3dbuildings') && ( )} {startLoc && (
{vehicleType === 'cycling' ? '🚴' : '🚗'} Start Location
)} {waypoints.filter(w => w.loc).map((wp, idx) => (
Stop {idx + 1}
))} {endLoc && (
Destination
)} {Array.isArray(poiResults) && poiResults.map((poi: any, idx: number) => ( { if (setEndLoc && setEndQuery) { setEndLoc([parseFloat(poi.lon || poi.center?.[0]), parseFloat(poi.lat || poi.center?.[1])]); setEndQuery(poi.display_name || poi.place_name); setPoiResults([]); } } }} >
{poi.name || (poi.display_name || poi.place_name || '').split(',')[0]}
))}
{showIncidents && incidentsData && incidentsData.map((incident: any, idx: number) => ( setHoveredIncident(incident), mouseout: () => setHoveredIncident(null) }} >
{incident.type.toUpperCase()}

Severity: {incident.severity}

))}
{activeLayers.includes('measure') && measurePoints.length > 0 && ( <> [lat, lng])} pathOptions={{ color: '#10b981', weight: 3, dashArray: '5, 5' }} /> {measurePoints.map((pt, i) => (
Point {i + 1}
))} {measurePoints.length > 1 && (
Distance: {measureDistance > 1000 ? `${(measureDistance / 1000).toFixed(2)} km` : `${Math.round(measureDistance)} m`}
)} )} {previousRoutePositions && ( )} {alternativeRoutePositions.map((positions, idx) => ( ))} {routePositions.length > 0 && ( <> {showRoutePlayback ? ( ) : ( <> {riskData && riskData.length > 0 && ( )} )} )} {contextMenu && (
)}
{activeLayers.includes('measure') && (
Measure Distance
{measurePoints.length > 0 && ( )}
{measurePoints.length > 0 ? (
Points {measurePoints.length}
Total Distance {measureDistance > 1000 ? `${(measureDistance / 1000).toFixed(2)} km` : `${Math.round(measureDistance)} m` }
{measurePoints.length > 1 && (
{measurePoints.slice(0, -1).map((pt, i) => { const p1 = pt; const p2 = measurePoints[i + 1]; const R = 6371e3; const φ1 = p1[1] * Math.PI/180; const φ2 = p2[1] * Math.PI/180; const Δφ = (p2[1]-p1[1]) * Math.PI/180; const Δλ = (p2[0]-p1[0]) * Math.PI/180; const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) * Math.sin(Δλ/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); const segDist = R * c; return (
{segDist > 1000 ? `${(segDist/1000).toFixed(1)}km` : `${Math.round(segDist)}m`}
); })}
)}
) : (
Click on the map to add measurement points
)}
)}
); }