SafeRoute / src /components /Sidebar.tsx
ayushsahu45's picture
Upload 10 files
25e36e5 verified
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<any[]>([]);
const [endResults, setEndResults] = useState<any[]>([]);
const [waypointResults, setWaypointResults] = useState<Record<number, any[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showSafetyDetails, setShowSafetyDetails] = useState(false);
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [voiceNavActive, setVoiceNavActive] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const startInputRef = useRef<HTMLInputElement>(null);
const endInputRef = useRef<HTMLInputElement>(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 (
<motion.div
initial={{ x: -400 }}
animate={{ x: 0 }}
style={{ width: `${width}px` }}
className="h-full bg-white dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-800 flex flex-col shadow-2xl z-10 transition-colors duration-300 relative shrink-0"
>
{/* Drag Handle */}
<div
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-emerald-500/50 active:bg-emerald-500 z-50 transition-colors"
onMouseDown={() => setIsResizing(true)}
/>
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50 flex items-center gap-2">
<Activity className="text-emerald-500" />
SafeRoute
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">Smart Traffic & Risk Prediction</p>
</div>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors text-zinc-600 dark:text-zinc-400"
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
</div>
<div className="p-6 flex-1 overflow-y-auto space-y-6">
{/* Search Section */}
<div className="space-y-4">
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setVehicleType('driving')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border transition-colors ${
vehicleType === 'driving'
? 'bg-emerald-500/10 border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
: 'border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<Car className="w-4 h-4" /> Car
</button>
<button
type="button"
onClick={() => setVehicleType('cycling')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border transition-colors ${
vehicleType === 'cycling'
? 'bg-emerald-500/10 border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
: 'border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<Bike className="w-4 h-4" /> Bike
</button>
</div>
<div className="relative">
<label className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1 block">Origin</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
<input
ref={startInputRef}
type="text"
value={startQuery}
onChange={(e) => { 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"
/>
<button
onClick={handleUseCurrentLocation}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-emerald-500 transition-colors"
title="Use Current Location"
>
<Crosshair size={18} />
</button>
</div>
{startResults.length > 0 && (
<div className="absolute z-20 w-full mt-1 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-xl overflow-hidden">
{startResults.map((res: any) => (
<button
key={res.id}
className="w-full text-left px-4 py-3 hover:bg-zinc-50 dark:hover:bg-zinc-700 text-sm text-zinc-700 dark:text-zinc-300 transition-colors border-b border-zinc-100 dark:border-zinc-700/50 last:border-0"
onClick={() => {
setStartQuery(res.place_name);
setStartLoc(res.center);
setStartResults([]);
saveToRecents(res.place_name, res.center);
}}
>
{res.place_name}
</button>
))}
</div>
)}
</div>
<div className="flex justify-center -my-2 relative z-10">
<button
onClick={handleReverse}
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 p-2 rounded-full shadow-sm hover:bg-zinc-50 dark:hover:bg-zinc-700 text-zinc-500 dark:text-zinc-400 hover:text-emerald-500 transition-colors"
title="Reverse Origin and Destination"
>
<ArrowUpDown size={16} />
</button>
</div>
<div className="relative">
<label className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1 block">Destination</label>
<div className="relative">
<Navigation className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
<input
ref={endInputRef}
type="text"
value={endQuery}
onChange={(e) => { 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"
/>
</div>
{endResults.length > 0 && (
<div className="absolute z-20 w-full mt-1 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-xl overflow-hidden">
{endResults.map((res: any) => (
<button
key={res.id}
className="w-full text-left px-4 py-3 hover:bg-zinc-50 dark:hover:bg-zinc-700 text-sm text-zinc-700 dark:text-zinc-300 transition-colors border-b border-zinc-100 dark:border-zinc-700/50 last:border-0"
onClick={() => {
setEndQuery(res.place_name);
setEndLoc(res.center);
setEndResults([]);
saveToRecents(res.place_name, res.center);
}}
>
{res.place_name}
</button>
))}
</div>
)}
</div>
{waypoints.map((wp) => (
<div key={wp.id} className="relative">
<label className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1 block flex items-center gap-1">
<span className="w-4 h-4 bg-blue-500 text-white rounded-full flex items-center justify-center text-[8px] font-bold">{waypoints.indexOf(wp) + 1}</span>
Stop {waypoints.indexOf(wp) + 1}
</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-500" size={18} />
<input
type="text"
value={wp.query}
onChange={(e) => 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`}
/>
<button
onClick={() => handleRemoveWaypoint(wp.id)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-rose-500 transition-colors"
aria-label={`Remove waypoint ${waypoints.indexOf(wp) + 1}`}
>
<X size={16} />
</button>
</div>
{waypointResults[wp.id]?.length > 0 && (
<div className="absolute z-20 w-full mt-1 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-xl overflow-hidden">
{waypointResults[wp.id].map((res: any) => (
<button
key={res.id}
className="w-full text-left px-4 py-3 hover:bg-blue-50 dark:hover:bg-blue-500/20 text-sm text-zinc-700 dark:text-zinc-300 transition-colors border-b border-zinc-100 dark:border-zinc-700/50 last:border-0"
onClick={() => {
handleSelectWaypoint(wp.id, res);
saveToRecents(res.place_name, res.center);
}}
>
{res.place_name}
</button>
))}
</div>
)}
</div>
))}
{waypoints.length < 3 && (
<button
onClick={handleAddWaypoint}
className="flex items-center justify-center gap-2 py-2 text-sm text-blue-500 hover:text-blue-600 dark:text-blue-400 font-medium transition-colors border border-dashed border-blue-300 dark:border-blue-700 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10"
>
<Plus size={16} /> Add Stop
</button>
)}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 rounded-xl p-3 flex items-start gap-2"
>
<AlertCircle size={16} className="text-rose-500 shrink-0 mt-0.5" />
<p className="text-rose-600 dark:text-rose-400 text-sm flex-1">{error}</p>
</motion.div>
)}
</AnimatePresence>
<div className="flex gap-2">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleCalculate}
disabled={loading}
className="flex-1 bg-zinc-900 dark:bg-zinc-100 text-zinc-50 dark:text-zinc-900 font-semibold py-3 rounded-xl shadow-[0_0_15px_rgba(0,0,0,0.1)] dark:shadow-[0_0_15px_rgba(255,255,255,0.1)] hover:shadow-[0_0_20px_rgba(0,0,0,0.15)] dark:hover:shadow-[0_0_20px_rgba(255,255,255,0.2)] transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1 }}>
<RefreshCw size={18} />
</motion.div>
) : routeData ? (
<><RefreshCw size={18} /> Recalculate</>
) : (
'Calculate Route'
)}
</motion.button>
{routeData && (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleShareRoute}
className="px-4 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold rounded-xl hover:bg-emerald-500/20 transition-colors"
title="Share Route"
>
<Share2 size={18} />
</motion.button>
)}
{routeData && (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => {
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
</motion.button>
)}
</div>
<div className="flex gap-2">
<button
onClick={onOpenTripHistory}
className="flex-1 flex items-center justify-center gap-2 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-xl text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors font-medium"
>
<Clock size={14} /> Trip History
</button>
{routeData && (
<button
onClick={handleVoiceNav}
className={`flex items-center justify-center gap-2 px-4 py-2 text-sm border rounded-xl transition-colors font-medium ${
voiceNavActive
? 'bg-emerald-500/10 border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
title={voiceNavActive ? 'Stop voice navigation' : 'Start voice navigation'}
>
{voiceNavActive ? <MicOff size={14} /> : <Mic size={14} />}
{voiceNavActive ? 'Stop Audio' : 'Voice Nav'}
</button>
)}
{routeData && startLoc && (
<button
onClick={() => onOpenIncidentReport?.(startLoc)}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm border border-rose-200 dark:border-rose-500/30 rounded-xl text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors font-medium"
>
<AlertTriangle size={14} /> Report
</button>
)}
</div>
<div className="grid grid-cols-1 gap-2 pt-2">
<button
onClick={() => setShowIncidents(!showIncidents)}
className={`flex items-center justify-between p-3 border rounded-xl transition-colors text-sm ${
showIncidents
? 'bg-amber-500/10 border-amber-500/50 text-amber-600 dark:text-amber-400'
: 'bg-zinc-50 dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="font-medium">Incidents</span>
</div>
<div className={`w-10 h-5 rounded-full relative transition-colors ${showIncidents ? 'bg-amber-500' : 'bg-zinc-300 dark:bg-zinc-700'}`}>
<div className={`w-3 h-3 rounded-full bg-white absolute top-1 transition-transform ${showIncidents ? 'translate-x-6' : 'translate-x-1'}`} />
</div>
</button>
{routeData && (
<button
onClick={() => setShowRoutePlayback(!showRoutePlayback)}
className={`flex items-center justify-between p-3 border rounded-xl transition-colors text-sm ${
showRoutePlayback
? 'bg-emerald-500/10 border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
: 'bg-zinc-50 dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" />
<span className="font-medium">Route Playback</span>
</div>
<div className={`w-10 h-5 rounded-full relative transition-colors ${showRoutePlayback ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-700'}`}>
<div className={`w-3 h-3 rounded-full bg-white absolute top-1 transition-transform ${showRoutePlayback ? 'translate-x-6' : 'translate-x-1'}`} />
</div>
</button>
)}
{showRoutePlayback && (
<div className="p-3 bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Playback Speed</span>
<span className="text-xs font-bold text-zinc-900 dark:text-zinc-100">{playbackSpeed}x</span>
</div>
<input
type="range"
min="0.5" max="5" step="0.5"
value={playbackSpeed}
onChange={(e) => setPlaybackSpeed(parseFloat(e.target.value))}
className="w-full accent-emerald-500"
/>
</div>
)}
</div>
</div>
{/* Dashboard Section */}
<AnimatePresence>
{routeData && riskData && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-4 pt-4 border-t border-zinc-200 dark:border-zinc-800"
>
{/* Alternative Routes */}
{/* @ts-ignore */}
{alternativeRoutes && alternativeRoutes.length > 0 && (
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-200 dark:border-zinc-800">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3 flex items-center gap-2">
<ArrowUpDown size={16} /> Alternative Routes
</h3>
<div className="space-y-2">
{/* @ts-ignore */}
{alternativeRoutes.map((altRoute: any, idx: number) => (
<button
key={idx}
onClick={async () => {
setLoading(true);
try {
const weatherCondition = weather?.weather?.[0]?.main || 'Clear';
const timeOfDay = new Date().getHours();
const analysis = await analyzeRoute(altRoute.geometry.coordinates, weatherCondition, timeOfDay, vehicleType);
// Only update state if analysis succeeds
const currentMain = routeData;
setRouteData(altRoute);
const newAlts = alternativeRoutes.filter((_: any, i: number) => i !== idx);
if (currentMain) {
newAlts.unshift(currentMain);
}
setAlternativeRoutes(newAlts);
setRiskData(analysis.segments);
setIncidentsData(analysis.incidents);
} catch (err) {
console.error('Failed to recalculate risk for alternative route', err);
setError('Failed to analyze alternative route. Please try again.');
} finally {
setLoading(false);
}
}}
className="w-full flex items-center justify-between p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg hover:border-emerald-500 dark:hover:border-emerald-500 transition-colors text-left"
>
<div>
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">Route {idx + 1}</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{Math.round(altRoute.duration / 60)} min • {(altRoute.distance / 1000).toFixed(1)} km
</p>
</div>
<span className="text-xs font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-2 py-1 rounded">Select</span>
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 mb-2">
<Navigation size={16} />
<span className="text-xs font-medium uppercase">Distance</span>
</div>
<p className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
{(routeData.distance / 1000).toFixed(1)} <span className="text-sm text-zinc-500">km</span>
</p>
</div>
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 mb-2">
<Clock size={16} />
<span className="text-xs font-medium uppercase">Est. Time</span>
</div>
<p className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
{Math.round(routeData.duration / 60)} <span className="text-sm text-zinc-500">min</span>
</p>
</div>
</div>
<div className="bg-zinc-50 dark:bg-zinc-950 p-5 rounded-xl border border-zinc-200 dark:border-zinc-800 relative overflow-hidden">
<div className="flex items-center justify-between mb-4 relative z-10">
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<ShieldCheck size={18} />
<span className="text-sm font-medium uppercase">Safety Score</span>
<button
onClick={() => setShowSafetyDetails(!showSafetyDetails)}
className="ml-2 p-1 rounded-full hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
title="Why this score?"
>
<Info size={14} />
</button>
</div>
<span className={`text-2xl font-bold ${
riskLevel === 'low' ? 'text-emerald-500' :
riskLevel === 'moderate' ? 'text-amber-500' : 'text-rose-500'
}`}>
{safePercentage}%
</span>
</div>
<div className="h-2 w-full bg-zinc-200 dark:bg-zinc-800 rounded-full overflow-hidden relative z-10">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${safePercentage}%` }}
transition={{ duration: 1, ease: "easeOut" }}
className={`h-full rounded-full ${
riskLevel === 'low' ? 'bg-emerald-500' :
riskLevel === 'moderate' ? 'bg-amber-500' : 'bg-rose-500'
}`}
/>
</div>
<div className="mt-4 relative z-10">
{riskLevel === 'low' ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<p className="text-sm font-medium">Low Risk / Safe Route</p>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 ml-7">This route is safe and suitable for travel.</p>
</div>
) : riskLevel === 'moderate' ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm font-medium">Moderate Risk / Medium Incident</p>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 ml-7">This route has moderate risk. Stay alert while traveling.</p>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-rose-600 dark:text-rose-400">
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<p className="text-sm font-medium">High Risk / Severe Incident</p>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 ml-7">This route has high risk. Proceed with caution.</p>
</div>
)}
</div>
<AnimatePresence>
{showSafetyDetails && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4 p-3 bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 text-sm space-y-2 relative z-10 overflow-hidden"
>
<h4 className="font-semibold text-zinc-900 dark:text-zinc-100">Risk Analysis:</h4>
<ul className="space-y-1 text-zinc-600 dark:text-zinc-400">
<li className="flex justify-between">
<span>Overall Risk:</span>
<span className={`font-medium ${
riskLevel === 'low' ? 'text-emerald-500' :
riskLevel === 'moderate' ? 'text-amber-500' : 'text-rose-500'
}`}>
{(overallRisk * 100).toFixed(0)}%
</span>
</li>
<li className="flex justify-between">
<span>Base Route Risk:</span>
<span>{(baseRisk * 100).toFixed(0)}%</span>
</li>
{incidentPenalty > 0 && (
<li className="flex justify-between">
<span>Incident Penalty:</span>
<span>+{(incidentPenalty * 100).toFixed(0)}%</span>
</li>
)}
<li className="flex justify-between">
<span>High Risk Segments:</span>
<span>{highRiskSegments}</span>
</li>
<li className="flex justify-between">
<span>Moderate Risk Segments:</span>
<span>{moderateRiskSegments}</span>
</li>
<li className="flex justify-between">
<span>Weather:</span>
<span>{weather?.weather?.[0]?.main || 'Clear'}</span>
</li>
<li className="flex justify-between">
<span>Time:</span>
<span>{new Date().getHours() >= 22 || new Date().getHours() <= 4 ? 'Night' : 'Day'}</span>
</li>
<li className="flex justify-between">
<span>Vehicle:</span>
<span>{vehicleType === 'cycling' ? 'Bicycle' : 'Car'}</span>
</li>
</ul>
</motion.div>
)}
</AnimatePresence>
{/* Background glow */}
<div className={`absolute -right-10 -bottom-10 w-32 h-32 blur-3xl opacity-20 dark:opacity-20 rounded-full ${
riskLevel === 'low' ? 'bg-emerald-500' :
riskLevel === 'moderate' ? 'bg-amber-500' : 'bg-rose-500'
}`} />
</div>
{/* Map Legend */}
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-200 dark:border-zinc-800">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3 flex items-center gap-2">
<Map size={16} /> Map Legend
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500"></div>
<span className="text-zinc-600 dark:text-zinc-400">Low Risk / Safe Route</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500"></div>
<span className="text-zinc-600 dark:text-zinc-400">Moderate Risk / Medium Incident</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-rose-500"></div>
<span className="text-zinc-600 dark:text-zinc-400">High Risk / Severe Incident</span>
</div>
</div>
</div>
{weather && (
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<CloudRain className="text-zinc-500 dark:text-zinc-400" size={20} />
<div>
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-200">{weather.weather?.[0]?.main || 'Clear'}</p>
<p className="text-xs text-zinc-500">Current Weather</p>
</div>
</div>
<div className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{Math.round((weather.main?.temp || 298) - 273.15)}°C
</div>
</div>
)}
{loading && <DirectionsLoadingSkeleton />}
{!loading && routeData?.legs?.[0]?.steps && (
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
<ListOrdered size={16} /> Turn-by-Turn Directions
{voiceNavActive && (
<span className="text-[10px] px-2 py-0.5 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 rounded-full font-medium">
Voice Active
</span>
)}
</h3>
{voiceNavActive && (
<button
onClick={() => { stopSpeaking(); setVoiceNavActive(false); }}
className="text-xs text-rose-500 hover:text-rose-600 font-medium"
>
Stop
</button>
)}
</div>
<div className="space-y-3 max-h-60 overflow-y-auto pr-2 custom-scrollbar">
{routeData.legs[0].steps.map((step: any, idx: number) => (
<div
key={idx}
className={`flex gap-3 text-sm border-b border-zinc-200 dark:border-zinc-800 pb-2 last:border-0 transition-all cursor-pointer rounded-lg p-1 -m-1 ${
idx === currentStepIndex && voiceNavActive
? 'bg-emerald-50 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
onClick={() => {
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);
}
}}
>
<div className="mt-0.5 shrink-0">
{idx === currentStepIndex && voiceNavActive ? (
<div className="w-6 h-6 bg-emerald-500 text-white rounded-full flex items-center justify-center">
<Volume2 size={12} />
</div>
) : (
<div className="w-6 h-6 bg-zinc-200 dark:bg-zinc-700 rounded-full flex items-center justify-center text-xs font-bold">
{idx + 1}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm ${idx === currentStepIndex && voiceNavActive ? 'font-semibold' : ''}`}>
{step.maneuver.instruction || `${step.maneuver.type} ${step.maneuver.modifier || ''} ${step.name ? 'onto ' + step.name : ''}`}
</p>
{step.distance > 0 && <p className="text-xs mt-0.5 opacity-70">{(step.distance).toFixed(0)}m</p>}
</div>
<button
onClick={(e) => { e.stopPropagation(); speakDirection(step); announceToScreenReader(step.maneuver?.instruction || step.maneuver?.type); }}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded transition-all shrink-0"
title="Speak this direction"
aria-label="Speak this direction"
>
<Volume2 size={12} />
</button>
</div>
))}
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}