SafeRoute / src /components /MapView.tsx
ayushsahu45's picture
Upload 10 files
25e36e5 verified
import { AlertTriangle, Construction, Crosshair, MapPin, Navigation } from 'lucide-react';
import 'maplibre-gl/dist/maplibre-gl.css';
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import Map, { FullscreenControl, GeolocateControl, Layer, Marker, NavigationControl, Popup, Source } from 'react-map-gl/maplibre';
import FloatingSearchBar from './FloatingSearchBar';
import { MapLayersMenu } from './MapLayersMenu';
interface MapViewProps {
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;
}
export default function MapView({
routeData, alternativeRoutes, riskData, previousRouteData, previousRiskData, incidentsData,
startLoc, endLoc,
setStartLoc, setEndLoc,
setStartQuery, setEndQuery,
theme, showIncidents, showRoutePlayback, playbackSpeed = 1, mapStyleType = 'default', setMapStyleType,
activeLayers = [], setActiveLayers
}: MapViewProps) {
const [viewState, setViewState] = useState({
longitude: 77.4126, // Default to India center roughly
latitude: 23.2599,
zoom: 5
});
const [progress, setProgress] = useState(1);
const [contextMenu, setContextMenu] = useState<{lng: number, lat: number, x: number, y: number} | null>(null);
const [hoveredIncident, setHoveredIncident] = useState<any | null>(null);
const [hoveredSegment, setHoveredSegment] = useState<any | null>(null);
const [poiResults, setPoiResults] = useState<any[]>([]);
// Map styles based on theme and mapStyleType using completely free providers
const getMapStyle = () => {
if (mapStyleType === 'satellite') {
return {
version: 8,
sources: {
'esri-satellite': {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
attribution: '&copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
}
},
layers: [
{
id: 'satellite',
type: 'raster',
source: 'esri-satellite',
minzoom: 0,
maxzoom: 22
}
]
};
}
if (mapStyleType === 'navigation') {
return "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json";
}
// Default / Fallback to Carto
return theme === 'dark'
? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json";
};
useEffect(() => {
if (startLoc) {
setViewState(prev => ({
...prev,
longitude: startLoc[0],
latitude: startLoc[1],
zoom: 12
}));
}
}, [startLoc]);
useEffect(() => {
if (!riskData || riskData.length === 0) {
setProgress(1);
return;
}
setProgress(0);
let start: number;
let animationFrameId: number;
// Base duration is 2 seconds for initial load, 10 seconds for playback
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) {
// Loop playback
start = timestamp;
animationFrameId = requestAnimationFrame(animate);
}
};
animationFrameId = requestAnimationFrame(animate);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, [riskData, showRoutePlayback, playbackSpeed]);
const geojson = useMemo(() => {
if (!riskData || riskData.length === 0) return null;
const visibleCount = Math.max(1, Math.floor(riskData.length * progress));
const visibleSegments = riskData.slice(0, visibleCount);
return {
type: 'FeatureCollection',
features: visibleSegments.map((segment: any) => ({
type: 'Feature',
properties: {
risk: segment.risk_probability || segment.risk_score || 0,
traffic: segment.traffic_level || 0,
explanation: segment.explanation || 'No specific risk factors'
},
geometry: {
type: 'LineString',
coordinates: [segment.start, segment.end]
}
}))
};
}, [riskData, progress]);
const previousGeojson = useMemo(() => {
if (!previousRouteData || !previousRouteData.geometry) return null;
return {
type: 'Feature',
properties: {},
geometry: previousRouteData.geometry
};
}, [previousRouteData]);
const alternativeGeojson = useMemo(() => {
if (!alternativeRoutes || alternativeRoutes.length === 0) return null;
const validRoutes = alternativeRoutes.filter((r: any) => r && r.geometry);
if (validRoutes.length === 0) return null;
return {
type: 'FeatureCollection',
features: validRoutes.map((route: any) => ({
type: 'Feature',
properties: {},
geometry: route.geometry
}))
};
}, [alternativeRoutes]);
const [measurePoints, setMeasurePoints] = useState<[number, number][]>([]);
// Calculate distance for measure tool
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];
// Haversine formula
const R = 6371e3; // metres
const φ1 = p1[1] * Math.PI/180; // φ, λ in radians
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.cos1) * Math.cos2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
dist += R * c;
}
return dist;
}, [measurePoints]);
const [bearing, setBearing] = useState(0);
return (
<div className="w-full h-full relative" onContextMenu={(e) => e.preventDefault()}>
<div className="absolute top-4 left-4 z-[1000]">
<FloatingSearchBar
setEndLoc={setEndLoc}
setEndQuery={setEndQuery}
viewState={viewState}
/>
</div>
<div className="absolute top-4 right-4 z-[1000]">
<MapLayersMenu
mapStyleType={mapStyleType}
setMapStyleType={setMapStyleType}
activeLayers={activeLayers}
setActiveLayers={(layers) => {
if (setActiveLayers) setActiveLayers(layers);
if (!layers.includes('measure')) setMeasurePoints([]);
}}
/>
</div>
<button
onClick={() => {
setViewState(v => ({ ...v, bearing: 0 }));
setBearing(0);
}}
className="absolute top-32 right-4 z-[1000] p-2.5 bg-white dark:bg-zinc-900 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all"
title="Reset bearing to North"
>
<Navigation
size={22}
className="transition-transform duration-300"
style={{ transform: `rotate(${-bearing}deg)` }}
/>
</button>
<Map
{...viewState}
onMove={evt => {
setViewState(evt.viewState);
setBearing(evt.viewState.bearing || 0);
}}
mapStyle={getMapStyle() as any}
interactiveLayerIds={geojson ? ['route-layer'] : []}
onMouseMove={(e) => {
if (e.features && e.features.length > 0) {
const feature = e.features[0];
if (feature.layer.id === 'route-layer') {
setHoveredSegment({
lng: e.lngLat.lng,
lat: e.lngLat.lat,
properties: feature.properties
});
}
} else {
setHoveredSegment(null);
}
}}
onMouseLeave={() => setHoveredSegment(null)}
onContextMenu={(e) => {
e.originalEvent.preventDefault();
setContextMenu({
lng: e.lngLat.lng,
lat: e.lngLat.lat,
x: e.point.x,
y: e.point.y
});
}}
onClick={(e) => {
if (activeLayers.includes('measure')) {
setMeasurePoints([...measurePoints, [e.lngLat.lng, e.lngLat.lat]]);
return;
}
setContextMenu(null);
setPoiResults([]);
}}
>
<NavigationControl position="bottom-right" />
<GeolocateControl position="bottom-right" />
<FullscreenControl position="bottom-right" />
{startLoc && (
<Marker longitude={startLoc[0]} latitude={startLoc[1]} anchor="bottom">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-emerald-500 drop-shadow-lg"
>
<MapPin size={36} fill="currentColor" className={theme === 'dark' ? "text-zinc-950" : "text-white"} />
</motion.div>
</Marker>
)}
{endLoc && (
<Marker longitude={endLoc[0]} latitude={endLoc[1]} anchor="bottom">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-rose-500 drop-shadow-lg"
>
<MapPin size={36} fill="currentColor" className={theme === 'dark' ? "text-zinc-950" : "text-white"} />
</motion.div>
</Marker>
)}
<AnimatePresence>
{Array.isArray(poiResults) && poiResults.map((poi: any, idx: number) => (
<Marker key={`poi-${idx}`} longitude={parseFloat(poi.lon || poi.center?.[0])} latitude={parseFloat(poi.lat || poi.center?.[1])} anchor="bottom">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="flex flex-col items-center text-blue-500 drop-shadow-md cursor-pointer hover:scale-110 transition-transform"
onClick={(e) => {
e.originalEvent.stopPropagation();
if (setEndLoc && setEndQuery) {
setEndLoc([parseFloat(poi.lon || poi.center?.[0]), parseFloat(poi.lat || poi.center?.[1])]);
setEndQuery(poi.display_name || poi.place_name);
setPoiResults([]);
}
}}
>
<MapPin size={28} fill="currentColor" className={theme === 'dark' ? "text-zinc-950" : "text-white"} />
<div className="mt-1 px-2 py-0.5 bg-white dark:bg-zinc-800 text-xs font-medium text-zinc-800 dark:text-zinc-200 rounded-md shadow-sm whitespace-nowrap max-w-[120px] truncate border border-zinc-200 dark:border-zinc-700">
{poi.name || (poi.display_name || poi.place_name || '').split(',')[0]}
</div>
</motion.div>
</Marker>
))}
</AnimatePresence>
<AnimatePresence>
{showIncidents && incidentsData && incidentsData.map((incident: any, idx: number) => (
<Marker key={idx} longitude={incident.location[0]} latitude={incident.location[1]} anchor="center">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
onMouseEnter={() => setHoveredIncident(incident)}
onMouseLeave={() => setHoveredIncident(null)}
className={`p-2 rounded-full shadow-lg cursor-pointer transition-transform ${
incident.type.toLowerCase() === 'accident'
? (incident.severity.toLowerCase() === 'high' ? 'bg-rose-600 text-white ring-4 ring-rose-600/30 scale-125 z-10' : 'bg-rose-400 text-white')
: 'bg-amber-500 text-white'
}`}
>
{incident.type.toLowerCase() === 'accident' ? <AlertTriangle size={incident.severity.toLowerCase() === 'high' ? 20 : 16} /> : <Construction size={16} />}
</motion.div>
</Marker>
))}
</AnimatePresence>
{hoveredIncident && (
<Popup
longitude={hoveredIncident.location[0]}
latitude={hoveredIncident.location[1]}
anchor="bottom"
offset={[0, -10]}
closeButton={false}
closeOnClick={false}
className="z-50"
>
<div className="p-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
<div className="flex items-center gap-2 mb-1">
{hoveredIncident.type.toLowerCase() === 'accident' ? <AlertTriangle size={14} className="text-rose-500" /> : <Construction size={14} className="text-amber-500" />}
<span className="font-bold">{hoveredIncident.type.toUpperCase()}</span>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Severity: <span className={`font-semibold ${hoveredIncident.severity.toLowerCase() === 'high' ? 'text-rose-500' : 'text-amber-500'}`}>{hoveredIncident.severity}</span>
</p>
</div>
</Popup>
)}
{hoveredSegment && (
<Popup
longitude={hoveredSegment.lng}
latitude={hoveredSegment.lat}
anchor="bottom"
offset={[0, -10]}
closeButton={false}
closeOnClick={false}
className="z-50"
>
<div className="p-2 text-sm font-medium text-zinc-900 dark:text-zinc-100 max-w-[200px]">
<div className="flex items-center gap-2 mb-1">
<Navigation size={14} className="text-emerald-500" />
<span className="font-bold">Route Segment</span>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-1">
Risk Score: <span className={`font-semibold ${(hoveredSegment.properties?.risk || 0) > 0.7 ? 'text-rose-500' : (hoveredSegment.properties?.risk || 0) > 0.4 ? 'text-amber-500' : 'text-emerald-500'}`}>{((hoveredSegment.properties?.risk || 0) * 100).toFixed(0)}%</span>
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-1">
Traffic: <span className="font-semibold">{((hoveredSegment.properties?.traffic || 0) * 100).toFixed(0)}%</span>
</p>
<p className="text-xs text-zinc-600 dark:text-zinc-300">
{hoveredSegment.properties?.explanation || 'No specific risk factors'}
</p>
</div>
</Popup>
)}
{activeLayers.includes('transit') && (
<Source id="transit" type="raster" tiles={['https://tile.waymarkedtrails.org/riding/{z}/{x}/{y}.png']} tileSize={256}>
<Layer id="transit-layer" type="raster" minzoom={0} maxzoom={22} />
</Source>
)}
{activeLayers.includes('biking') && (
<Source id="biking" type="raster" tiles={['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png']} tileSize={256}>
<Layer id="biking-layer" type="raster" minzoom={0} maxzoom={22} />
</Source>
)}
{activeLayers.includes('terrain') && (
<Source id="terrain" type="raster" tiles={['https://tile.opentopomap.org/{z}/{x}/{y}.png']} tileSize={256}>
<Layer id="terrain-layer" type="raster" minzoom={0} maxzoom={22} />
</Source>
)}
{activeLayers.includes('traffic') && (
<Source id="traffic" type="raster" tiles={['https://tile.waymarkedtrails.org/slopes/{z}/{x}/{y}.png']} tileSize={256}>
<Layer id="traffic-layer" type="raster" minzoom={0} maxzoom={22} paint={{ 'raster-opacity': 0.6 }} />
</Source>
)}
{activeLayers.includes('streetview') && (
<Source id="streetview" type="raster" tiles={['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png']} tileSize={256}>
<Layer id="streetview-layer" type="raster" minzoom={0} maxzoom={22} paint={{ 'raster-opacity': 0.8 }} />
</Source>
)}
{activeLayers.includes('wildfires') && (
<Source id="wildfires" type="raster" tiles={['https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=9de243494c0b295cca9337e1e96b00e2']} tileSize={256}>
<Layer id="wildfires-layer" type="raster" minzoom={0} maxzoom={22} paint={{ 'raster-opacity': 0.7 }} />
</Source>
)}
{activeLayers.includes('airquality') && (
<Source id="airquality" type="raster" tiles={['https://tiles.aqicn.org/tiles/usepa-aqi/{z}/{x}/{y}.png?token=demo']} tileSize={256}>
<Layer id="airquality-layer" type="raster" minzoom={0} maxzoom={22} paint={{ 'raster-opacity': 0.6 }} />
</Source>
)}
{activeLayers.includes('traveltime') && startLoc && (
<Source id="traveltime" type="geojson" data={{
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [
Array.from({ length: 36 }).map((_, i) => {
const angle = (i * 10 * Math.PI) / 180;
const radius = 0.05; // approx 5km in degrees
return [
startLoc[0] + radius * Math.cos(angle),
startLoc[1] + radius * Math.sin(angle)
];
}).concat([[startLoc[0] + 0.05, startLoc[1]]])
]
}
}}>
<Layer
id="traveltime-layer"
type="fill"
paint={{
'fill-color': '#3b82f6',
'fill-opacity': 0.2
}}
/>
<Layer
id="traveltime-layer-outline"
type="line"
paint={{
'line-color': '#3b82f6',
'line-width': 2,
'line-dasharray': [2, 2]
}}
/>
</Source>
)}
{activeLayers.includes('measure') && measurePoints.length > 0 && (
<>
<Source id="measure-line" type="geojson" data={{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: measurePoints
}
}}>
<Layer
id="measure-line-layer"
type="line"
paint={{
'line-color': '#10b981',
'line-width': 3,
'line-dasharray': [2, 2]
}}
/>
</Source>
{measurePoints.map((pt, i) => (
<Marker key={`measure-pt-${i}`} longitude={pt[0]} latitude={pt[1]}>
<div className="w-3 h-3 bg-white border-2 border-emerald-500 rounded-full shadow-md" />
</Marker>
))}
{measurePoints.length > 1 && (
<Popup
longitude={measurePoints[measurePoints.length - 1][0]}
latitude={measurePoints[measurePoints.length - 1][1]}
closeButton={false}
closeOnClick={false}
anchor="bottom"
offset={[0, -10]}
>
<div className="px-2 py-1 text-sm font-bold text-emerald-600">
{measureDistance > 1000
? `${(measureDistance / 1000).toFixed(2)} km`
: `${Math.round(measureDistance)} m`}
</div>
</Popup>
)}
</>
)}
{previousGeojson && (
<Source id="previous-route" type="geojson" data={previousGeojson as any}>
<Layer
id="previous-route-layer"
type="line"
layout={{
'line-join': 'round',
'line-cap': 'round'
}}
paint={{
'line-width': 4,
'line-color': theme === 'dark' ? '#52525b' : '#a1a1aa', // zinc-600 or zinc-400
'line-dasharray': [2, 2],
'line-opacity': 0.8
}}
/>
</Source>
)}
{alternativeGeojson && (
<Source id="alternative-routes" type="geojson" data={alternativeGeojson as any}>
<Layer
id="alternative-routes-layer"
type="line"
layout={{
'line-join': 'round',
'line-cap': 'round'
}}
paint={{
'line-width': 4,
'line-color': theme === 'dark' ? '#3b82f6' : '#60a5fa', // blue-500 or blue-400
'line-opacity': 0.4
}}
/>
</Source>
)}
{geojson && (
<Source id="route" type="geojson" data={geojson as any}>
<Layer
id="route-layer"
type="line"
layout={{
'line-join': 'round',
'line-cap': 'round'
}}
paint={{
'line-width': [
'interpolate',
['linear'],
['get', 'traffic'],
0, 6,
0.5, 8,
1.0, 12
],
'line-color': [
'interpolate',
['linear'],
['get', 'risk'],
0, '#10b981', // Low risk (< 0.4) Emerald
0.4, '#f59e0b', // Medium risk (0.4 - 0.7) Amber
0.7, '#f43f5e', // High risk (>= 0.7) Rose
1.0, '#9f1239' // Severe risk Dark Rose
]
}}
/>
</Source>
)}
{contextMenu && (
<Popup
longitude={contextMenu.lng}
latitude={contextMenu.lat}
anchor="top-left"
closeButton={false}
closeOnClick={false}
className="z-50"
offset={[0, 0]}
maxWidth="200px"
>
<div className="flex flex-col gap-1 p-1 bg-white dark:bg-zinc-900 rounded-lg shadow-xl border border-zinc-200 dark:border-zinc-800">
<button
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors text-left"
onClick={(e) => {
e.stopPropagation();
setStartLoc([contextMenu.lng, contextMenu.lat]);
setStartQuery(`${contextMenu.lat.toFixed(4)}, ${contextMenu.lng.toFixed(4)}`);
setContextMenu(null);
}}
>
<Crosshair size={16} className="text-emerald-500" /> Set as Origin
</button>
<button
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors text-left"
onClick={(e) => {
e.stopPropagation();
setEndLoc([contextMenu.lng, contextMenu.lat]);
setEndQuery(`${contextMenu.lat.toFixed(4)}, ${contextMenu.lng.toFixed(4)}`);
setContextMenu(null);
}}
>
<Navigation size={16} className="text-rose-500" /> Set as Destination
</button>
</div>
</Popup>
)}
</Map>
</div>
);
}