import { useState, useEffect, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Search, MapPin, Navigation, CloudRain, Clock, AlertTriangle, ShieldCheck, Activity, Bike, Car, Moon, Sun, Eye, EyeOff, Map, Crosshair, ListOrdered, Info, RefreshCw, ArrowUpDown, Volume2, VolumeX, Share2, BookmarkPlus, Plus, X, Mic, MicOff, AlertCircle } from 'lucide-react'; import { geocode, getRoute, getWeather, analyzeRoute } from '../lib/api'; import { speakDirection, stopSpeaking, isSpeaking } from '../lib/voiceNavigation'; import { RouteLoadingSkeleton, DirectionsLoadingSkeleton } from './SkeletonLoader'; import { useKeyboardShortcuts, announceToScreenReader } from '../hooks'; interface Waypoint { id: number; query: string; loc: [number, number] | null; } interface SidebarProps { setRouteData: (data: any) => void; setAlternativeRoutes: (data: any[]) => void; alternativeRoutes: any[]; setRiskData: (data: any) => void; setPreviousRouteData: (data: any) => void; setPreviousRiskData: (data: any) => void; setIncidentsData: (data: any) => void; setWeather: (data: any) => void; setStartLoc: (loc: [number, number] | null) => void; setEndLoc: (loc: [number, number] | null) => void; startLoc: [number, number] | null; endLoc: [number, number] | null; startQuery: string; setStartQuery: (query: string) => void; endQuery: string; setEndQuery: (query: string) => void; routeData: any; riskData: any; previousRouteData: any; previousRiskData: any; incidentsData: any; weather: any; theme: 'dark' | 'light'; setTheme: (theme: 'dark' | 'light') => void; vehicleType: 'driving' | 'cycling'; setVehicleType: (type: 'driving' | 'cycling') => void; showIncidents: boolean; setShowIncidents: (show: boolean) => void; showRoutePlayback: boolean; setShowRoutePlayback: (show: boolean) => void; playbackSpeed: number; setPlaybackSpeed: (speed: number) => void; onOpenIncidentReport?: (location: [number, number]) => void; onOpenTripHistory?: () => void; onToggleVoiceNav?: (enabled: boolean) => void; voiceNavEnabled?: boolean; } export default function Sidebar({ setRouteData, setAlternativeRoutes, alternativeRoutes, setRiskData, setPreviousRouteData, setPreviousRiskData, setIncidentsData, setWeather, setStartLoc, setEndLoc, startLoc, endLoc, startQuery, setStartQuery, endQuery, setEndQuery, routeData, riskData, previousRouteData, previousRiskData, incidentsData, weather, theme, setTheme, vehicleType, setVehicleType, showIncidents, setShowIncidents, showRoutePlayback, setShowRoutePlayback, playbackSpeed, setPlaybackSpeed, onOpenIncidentReport, onOpenTripHistory, onToggleVoiceNav, voiceNavEnabled = false }: SidebarProps) { const [startResults, setStartResults] = useState([]); const [endResults, setEndResults] = useState([]); const [waypointResults, setWaypointResults] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [showSafetyDetails, setShowSafetyDetails] = useState(false); const [waypoints, setWaypoints] = useState([]); const [voiceNavActive, setVoiceNavActive] = useState(false); const [currentStepIndex, setCurrentStepIndex] = useState(0); const startInputRef = useRef(null); const endInputRef = useRef(null); useKeyboardShortcuts([ { key: 'Enter', handler: () => handleCalculate(), description: 'Calculate route' }, { key: 'Escape', handler: () => { setError(''); setWaypoints([]); }, description: 'Close panels' }, { key: 'r', ctrl: true, handler: () => handleCalculate(), description: 'Calculate route' }, { key: 'k', ctrl: true, handler: () => startInputRef.current?.focus(), description: 'Focus start location' }, ]); const saveTripToHistory = (start: [number, number], end: [number, number], route: any, risk: any, w: any) => { try { const trip = { id: Date.now(), startName: startQuery || `${start[1].toFixed(4)}, ${start[0].toFixed(4)}`, endName: endQuery || `${end[1].toFixed(4)}, ${end[0].toFixed(4)}`, startLoc: start, endLoc: end, distance: route?.distance || 0, duration: route?.duration || 0, safetyScore: Math.round((1 - (risk?.reduce?.((a: number, b: any) => a + (b.risk_probability || 0), 0) / (risk?.length || 1) || 0)) * 100), riskLevel: (risk?.reduce?.((a: number, b: any) => a + (b.risk_probability || 0), 0) / (risk?.length || 1) || 0) >= 0.6 ? 'high' : (risk?.reduce?.((a: number, b: any) => a + (b.risk_probability || 0), 0) / (risk?.length || 1) || 0) >= 0.35 ? 'moderate' : 'low', weather: w?.weather?.[0]?.main || 'Clear', vehicleType, timestamp: Date.now() }; const history = JSON.parse(localStorage.getItem('tripHistory') || '[]'); history.unshift(trip); localStorage.setItem('tripHistory', JSON.stringify(history.slice(0, 50))); } catch {} }; const handleShareRoute = () => { if (!routeData) return; const url = `https://www.google.com/maps/dir/?api=1&origin=${startLoc?.[1]},${startLoc?.[0]}&destination=${endLoc?.[1]},${endLoc?.[0]}`; if (navigator.share) { navigator.share({ title: 'SafeRoute', text: `My route: ${startQuery} → ${endQuery}`, url }).catch(() => { navigator.clipboard.writeText(url); announceToScreenReader('Link copied to clipboard'); }); } else { navigator.clipboard.writeText(url); setError('Route link copied to clipboard!'); setTimeout(() => setError(''), 3000); } }; const handleVoiceNav = () => { if (!routeData?.legs?.[0]?.steps?.length) return; const steps = routeData.legs[0].steps; if (voiceNavActive) { stopSpeaking(); setVoiceNavActive(false); setCurrentStepIndex(0); } else { setVoiceNavActive(true); setCurrentStepIndex(0); speakDirection(steps[0]); announceToScreenReader(`Voice navigation started. First direction: ${steps[0].maneuver?.instruction || steps[0].maneuver?.type}`); } }; const handleAddWaypoint = () => { if (waypoints.length >= 3) return; setWaypoints(prev => [...prev, { id: Date.now(), query: '', loc: null }]); }; const handleRemoveWaypoint = (id: number) => { setWaypoints(prev => prev.filter(w => w.id !== id)); setWaypointResults(prev => { const n = {...prev}; delete n[id]; return n; }); }; const handleWaypointQueryChange = (id: number, query: string) => { setWaypoints(prev => prev.map(w => w.id === id ? { ...w, query, loc: null } : w)); const timer = setTimeout(async () => { if (query.length > 2) { try { const res = await geocode(query); setWaypointResults(prev => ({ ...prev, [id]: res })); } catch {} } else { setWaypointResults(prev => { const n = {...prev}; delete n[id]; return n; }); } }, 500); return () => clearTimeout(timer); }; const handleSelectWaypoint = (id: number, result: any) => { setWaypoints(prev => prev.map(w => w.id === id ? { ...w, query: result.place_name, loc: result.center } : w)); setWaypointResults(prev => { const n = {...prev}; delete n[id]; return n; }); }; useEffect(() => { if (voiceNavActive && routeData?.legs?.[0]?.steps?.length) { const steps = routeData.legs[0].steps; const handleKey = (e: KeyboardEvent) => { if (e.key === 'ArrowRight' && currentStepIndex < steps.length - 1) { const next = currentStepIndex + 1; setCurrentStepIndex(next); speakDirection(steps[next]); announceToScreenReader(`Step ${next + 1}: ${steps[next].maneuver?.instruction || steps[next].maneuver?.type}`); } else if (e.key === 'ArrowLeft' && currentStepIndex > 0) { const prev = currentStepIndex - 1; setCurrentStepIndex(prev); speakDirection(steps[prev]); announceToScreenReader(`Step ${prev + 1}: ${steps[prev].maneuver?.instruction || steps[prev].maneuver?.type}`); } else if (e.key === 'Escape') { stopSpeaking(); setVoiceNavActive(false); setCurrentStepIndex(0); } }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); } }, [voiceNavActive, routeData, currentStepIndex]); const saveToRecents = (placeName: string, loc: [number, number]) => { try { const recents = JSON.parse(localStorage.getItem('recentSearches') || '[]'); const newRecent = { id: Date.now(), name: placeName.split(',')[0], address: placeName, loc: loc, timestamp: Date.now() }; const filtered = recents.filter((r: any) => r.address !== placeName); const updated = [newRecent, ...filtered].slice(0, 20); localStorage.setItem('recentSearches', JSON.stringify(updated)); window.dispatchEvent(new Event('recentsUpdated')); } catch (e) { console.error('Failed to save recent:', e); } }; // Debounce search useEffect(() => { const timer = setTimeout(async () => { if (startQuery.length > 2 && !startLoc) { try { const res = await geocode(startQuery); setStartResults(res); } catch (e) {} } else { setStartResults([]); } }, 500); return () => clearTimeout(timer); }, [startQuery, startLoc]); useEffect(() => { const timer = setTimeout(async () => { if (endQuery.length > 2 && !endLoc) { try { const res = await geocode(endQuery); setEndResults(res); } catch (e) {} } else { setEndResults([]); } }, 500); return () => clearTimeout(timer); }, [endQuery, endLoc]); const handleUseCurrentLocation = () => { if ('geolocation' in navigator) { setLoading(true); navigator.geolocation.getCurrentPosition( async (position) => { const { latitude, longitude } = position.coords; setStartLoc([longitude, latitude]); setStartQuery('Current Location'); setLoading(false); }, (err) => { setError('Could not get current location. Please check permissions or allow location access.'); setLoading(false); } ); } else { setError('Geolocation is not supported by your browser.'); } }; const handleDetectCurrentLocation = () => { if (startQuery.toLowerCase().includes('current') && !startLoc) { handleUseCurrentLocation(); } }; const handleReverse = () => { const tempLoc = startLoc; const tempQuery = startQuery; setStartLoc(endLoc); setStartQuery(endQuery); setEndLoc(tempLoc); setEndQuery(tempQuery); }; const handleCalculate = async () => { if (!startLoc) { if (startQuery.toLowerCase().includes('current')) { handleUseCurrentLocation(); setError('Getting your current location...'); setTimeout(() => { if (startLoc) setError(''); }, 2000); return; } if (startResults.length > 0) { const firstResult = startResults[0]; setStartQuery(firstResult.place_name); setStartLoc(firstResult.center); setStartResults([]); setError('Using first search result for origin...'); setTimeout(() => setError(''), 2000); return; } setError('Please enter a start location and select it from the dropdown, or click the crosshair button for Current Location.'); startInputRef.current?.focus(); announceToScreenReader('Start location required. Please enter and select a start location.'); return; } if (!endLoc) { if (endResults.length > 0) { const firstResult = endResults[0]; setEndQuery(firstResult.place_name); setEndLoc(firstResult.center); setEndResults([]); setError('Using first search result for destination...'); setTimeout(() => setError(''), 2000); return; } setError('Please select a destination from the dropdown, or try a shorter search term.'); endInputRef.current?.focus(); announceToScreenReader('Destination required. Please enter and select a destination.'); return; } const unresolvedWaypoints = waypoints.filter(w => !w.loc); if (unresolvedWaypoints.length > 0) { setError(`Please select "${unresolvedWaypoints[0].query}" from the dropdown or remove it.`); return; } setError(''); setLoading(true); setVoiceNavActive(false); setCurrentStepIndex(0); stopSpeaking(); try { if (routeData) { setPreviousRouteData(routeData); setPreviousRiskData(riskData); } setRouteData(null); setRiskData(null); setAlternativeRoutes([]); const allPoints: [number, number][] = [startLoc]; waypoints.forEach(w => { if (w.loc) allPoints.push(w.loc); }); allPoints.push(endLoc); let primaryRoute: any = null; let altRoutes: any[] = []; if (allPoints.length === 2) { const routes = await getRoute(startLoc, endLoc, vehicleType); if (!routes || routes.length === 0) { throw new Error(`No route found between "${startQuery}" and "${endQuery}". Try different locations or check spelling.`); } primaryRoute = routes[0]; altRoutes = routes.slice(1); } else { let cumulativeRoute: any = { geometry: { coordinates: [] as [number, number][] }, distance: 0, duration: 0, legs: [] as any[] }; for (let i = 0; i < allPoints.length - 1; i++) { const segmentRoutes = await getRoute(allPoints[i], allPoints[i + 1], vehicleType); if (!segmentRoutes || segmentRoutes.length === 0) { throw new Error(`No route found between stop ${i + 1} and stop ${i + 2}. Try different waypoints.`); } const seg = segmentRoutes[0]; cumulativeRoute.geometry.coordinates.push(...seg.geometry.coordinates); cumulativeRoute.distance += seg.distance; cumulativeRoute.duration += seg.duration; if (seg.legs?.[0]?.steps) { cumulativeRoute.legs.push(...seg.legs); } } primaryRoute = cumulativeRoute; } const weatherData = await getWeather(startLoc[1], startLoc[0]); setWeather(weatherData); const weatherCondition = weatherData.weather?.[0]?.main || 'Clear'; const timeOfDay = new Date().getHours(); const analysis = await analyzeRoute(primaryRoute.geometry.coordinates, weatherCondition, timeOfDay, vehicleType); setRouteData(primaryRoute); setAlternativeRoutes(altRoutes); setRiskData(analysis.segments); setIncidentsData(analysis.incidents); saveTripToHistory(startLoc, endLoc, primaryRoute, analysis.segments, weatherData); const safetyScore = Math.round((1 - analysis.overallRisk) * 100); announceToScreenReader(`Route calculated! Distance: ${(primaryRoute.distance / 1000).toFixed(1)} kilometers. Safety score: ${safetyScore}%`); } catch (err: any) { console.error('Calculation Error:', err.response?.data || err.message || err); const msg = err.response?.data?.message || err.message; if (msg.includes('No route')) { setError(msg); } else if (msg.includes('geocoding') || msg.includes('coordinates')) { setError('Could not find those locations. Please check the addresses and try again.'); } else if (msg.includes('network') || msg.includes('fetch')) { setError('Network error. Check your internet connection and try again.'); } else { setError('Failed to calculate route. Please try different locations or try again later.'); } announceToScreenReader('Route calculation failed. ' + (err.message || 'Please try again.')); } finally { setLoading(false); } }; // Calculate base risk from risk data const baseRisk = riskData && riskData.length > 0 ? riskData.reduce((acc: number, seg: any) => acc + (seg.risk_probability || 0), 0) / riskData.length : 0; // Determine risk level from segment data const highRiskSegments = riskData ? riskData.filter((seg: any) => (seg.risk_probability || 0) >= 0.6).length : 0; const moderateRiskSegments = riskData ? riskData.filter((seg: any) => { const risk = seg.risk_probability || 0; return risk >= 0.35 && risk < 0.6; }).length : 0; // Calculate incident penalty const incidentPenalty = (incidentsData || []).reduce((acc: number, incident: any) => { let penalty = 0.03; if (incident.severity?.toLowerCase() === 'high') penalty += 0.08; else if (incident.severity?.toLowerCase() === 'medium') penalty += 0.05; if (incident.type?.toLowerCase() === 'accident') penalty += 0.03; return acc + penalty; }, 0); // Calculate overall risk const overallRisk = Math.min(0.95, baseRisk + incidentPenalty); // Determine risk level based on conditions let riskLevel: 'low' | 'moderate' | 'high' = 'low'; let riskMessage = 'This route is safe and suitable for travel.'; if (overallRisk >= 0.6 || highRiskSegments > (riskData?.length || 1) * 0.3) { riskLevel = 'high'; riskMessage = 'This route has high risk. Proceed with caution.'; } else if (overallRisk >= 0.35 || moderateRiskSegments > (riskData?.length || 1) * 0.4) { riskLevel = 'moderate'; riskMessage = 'This route has moderate risk. Stay alert while traveling.'; } // Calculate safety percentage (inverse of risk) const safePercentage = Math.round((1 - overallRisk) * 100); const [width, setWidth] = useState(384); // 384px is w-96 const [isResizing, setIsResizing] = useState(false); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return; // Calculate new width, constrained between 320px and 600px const newWidth = Math.max(320, Math.min(600, e.clientX)); setWidth(newWidth); }; const handleMouseUp = () => { setIsResizing(false); document.body.style.userSelect = ''; }; if (isResizing) { document.body.style.userSelect = 'none'; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } return () => { document.body.style.userSelect = ''; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isResizing]); return ( {/* Drag Handle */}
setIsResizing(true)} />

SafeRoute

Smart Traffic & Risk Prediction

{/* Search Section */}
{ setStartQuery(e.target.value); setStartLoc(null); }} placeholder="Enter start location..." className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl py-3 pl-10 pr-10 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all" aria-label="Start location input" />
{startResults.length > 0 && (
{startResults.map((res: any) => ( ))}
)}
{ setEndQuery(e.target.value); setEndLoc(null); }} placeholder="Enter destination..." className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all" aria-label="Destination input" />
{endResults.length > 0 && (
{endResults.map((res: any) => ( ))}
)}
{waypoints.map((wp) => (
handleWaypointQueryChange(wp.id, e.target.value)} placeholder={`Add stop ${waypoints.indexOf(wp) + 1}...`} className="w-full bg-blue-50/50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-800 rounded-xl py-3 pl-10 pr-10 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-sm" aria-label={`Waypoint ${waypoints.indexOf(wp) + 1} input`} />
{waypointResults[wp.id]?.length > 0 && (
{waypointResults[wp.id].map((res: any) => ( ))}
)}
))} {waypoints.length < 3 && ( )} {error && (

{error}

)}
{loading ? ( ) : routeData ? ( <> Recalculate ) : ( 'Calculate Route' )} {routeData && ( )} {routeData && ( { setRouteData(null); setAlternativeRoutes([]); setRiskData(null); setPreviousRouteData(null); setPreviousRiskData(null); setIncidentsData(null); setStartLoc(null); setEndLoc(null); setStartQuery(''); setEndQuery(''); setWaypoints([]); setVoiceNavActive(false); setCurrentStepIndex(0); stopSpeaking(); }} className="px-4 bg-rose-500/10 text-rose-600 dark:text-rose-400 font-semibold rounded-xl hover:bg-rose-500/20 transition-colors" title="Clear Route" > Clear )}
{routeData && ( )} {routeData && startLoc && ( )}
{routeData && ( )} {showRoutePlayback && (
Playback Speed {playbackSpeed}x
setPlaybackSpeed(parseFloat(e.target.value))} className="w-full accent-emerald-500" />
)}
{/* Dashboard Section */} {routeData && riskData && ( {/* Alternative Routes */} {/* @ts-ignore */} {alternativeRoutes && alternativeRoutes.length > 0 && (

Alternative Routes

{/* @ts-ignore */} {alternativeRoutes.map((altRoute: any, idx: number) => ( ))}
)}
Distance

{(routeData.distance / 1000).toFixed(1)} km

Est. Time

{Math.round(routeData.duration / 60)} min

Safety Score
{safePercentage}%
{riskLevel === 'low' ? (

Low Risk / Safe Route

This route is safe and suitable for travel.

) : riskLevel === 'moderate' ? (

Moderate Risk / Medium Incident

This route has moderate risk. Stay alert while traveling.

) : (

High Risk / Severe Incident

This route has high risk. Proceed with caution.

)}
{showSafetyDetails && (

Risk Analysis:

  • Overall Risk: {(overallRisk * 100).toFixed(0)}%
  • Base Route Risk: {(baseRisk * 100).toFixed(0)}%
  • {incidentPenalty > 0 && (
  • Incident Penalty: +{(incidentPenalty * 100).toFixed(0)}%
  • )}
  • High Risk Segments: {highRiskSegments}
  • Moderate Risk Segments: {moderateRiskSegments}
  • Weather: {weather?.weather?.[0]?.main || 'Clear'}
  • Time: {new Date().getHours() >= 22 || new Date().getHours() <= 4 ? 'Night' : 'Day'}
  • Vehicle: {vehicleType === 'cycling' ? 'Bicycle' : 'Car'}
)}
{/* Background glow */}
{/* Map Legend */}

Map Legend

Low Risk / Safe Route
Moderate Risk / Medium Incident
High Risk / Severe Incident
{weather && (

{weather.weather?.[0]?.main || 'Clear'}

Current Weather

{Math.round((weather.main?.temp || 298) - 273.15)}°C
)} {loading && } {!loading && routeData?.legs?.[0]?.steps && (

Turn-by-Turn Directions {voiceNavActive && ( Voice Active )}

{voiceNavActive && ( )}
{routeData.legs[0].steps.map((step: any, idx: number) => (
{ if (routeData?.legs?.[0]?.steps) { setCurrentStepIndex(idx); speakDirection(step); } }} role="button" tabIndex={0} aria-label={`Step ${idx + 1}: ${step.maneuver?.instruction || step.maneuver?.type}. Press to hear.`} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCurrentStepIndex(idx); speakDirection(step); } }} >
{idx === currentStepIndex && voiceNavActive ? (
) : (
{idx + 1}
)}

{step.maneuver.instruction || `${step.maneuver.type} ${step.maneuver.modifier || ''} ${step.name ? 'onto ' + step.name : ''}`}

{step.distance > 0 &&

{(step.distance).toFixed(0)}m

}
))}
)} )}
); }