feat: implement interactive Leaflet bus route map and geocoding script
This commit is contained in:
@@ -5,9 +5,11 @@ import type { BusRoute } from '@/types';
|
||||
|
||||
interface BusCardProps {
|
||||
route: BusRoute;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
export default function BusCard({ route }: BusCardProps) {
|
||||
export default function BusCard({ route, isSelected, onSelect }: BusCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -15,8 +17,14 @@ export default function BusCard({ route }: BusCardProps) {
|
||||
layout
|
||||
whileHover={{ y: -3 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-white border border-[#E5E7EB] rounded-2xl overflow-hidden"
|
||||
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
|
||||
onClick={onSelect}
|
||||
className={`bg-white border rounded-2xl overflow-hidden cursor-pointer transition-all ${
|
||||
isSelected ? 'border-transparent shadow-lg scale-[1.01]' : 'border-[#E5E7EB]'
|
||||
}`}
|
||||
style={{
|
||||
boxShadow: isSelected ? `0 10px 25px -5px ${route.color}25, 0 8px 10px -6px ${route.color}25` : '0 2px 15px -3px rgba(0,0,0,0.07)',
|
||||
outline: isSelected ? `2px solid ${route.color}` : 'none'
|
||||
}}
|
||||
>
|
||||
{/* Color Strip */}
|
||||
<div className="h-1.5" style={{ backgroundColor: route.color }} />
|
||||
@@ -71,7 +79,10 @@ export default function BusCard({ route }: BusCardProps) {
|
||||
|
||||
{/* Toggle Stops */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all"
|
||||
style={{
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
|
||||
212
src/components/BusRouteMap/BusRouteMap.tsx
Normal file
212
src/components/BusRouteMap/BusRouteMap.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { BusRoute } from '@/types';
|
||||
|
||||
// Standard Leaflet Icon fix for Vite
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: markerIcon,
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
shadowUrl: markerShadow,
|
||||
});
|
||||
|
||||
// Custom Premium DivIcons using Tailwind CSS
|
||||
const startIcon = L.divIcon({
|
||||
html: `<div class="flex items-center justify-center w-6 h-6 rounded-full bg-emerald-500 border-2 border-white shadow-md text-white text-[10px] font-bold">A</div>`,
|
||||
className: '',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
});
|
||||
|
||||
const stopIcon = L.divIcon({
|
||||
html: `<div class="w-3.5 h-3.5 rounded-full bg-blue-500 border-2 border-white shadow-sm hover:scale-125 transition-transform"></div>`,
|
||||
className: '',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
});
|
||||
|
||||
const campusIcon = L.divIcon({
|
||||
html: `<div class="flex items-center justify-center w-9 h-9 rounded-full bg-gradient-to-tr from-orange-500 to-amber-500 border-2 border-white shadow-lg text-white text-sm font-bold animate-pulse">🏫</div>`,
|
||||
className: '',
|
||||
iconSize: [36, 36],
|
||||
iconAnchor: [18, 18],
|
||||
});
|
||||
|
||||
// Helper component to auto-pan and fit the map bounds to the active route
|
||||
function MapUpdater({ bounds }: { bounds: L.LatLngBoundsExpression | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (bounds && (bounds as any).length > 0) {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14, animate: true });
|
||||
}
|
||||
}, [bounds, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface BusRouteMapProps {
|
||||
selectedRoute: BusRoute | null;
|
||||
allRoutes: BusRoute[];
|
||||
}
|
||||
|
||||
const DEFAULT_CENTER = [13.0118, 80.0214]; // RIT Campus default
|
||||
|
||||
export default function BusRouteMap({ selectedRoute, allRoutes }: BusRouteMapProps) {
|
||||
// Collect coordinates for the polyline path
|
||||
const pathCoordinates = useMemo(() => {
|
||||
if (!selectedRoute) return [];
|
||||
|
||||
const coords: [number, number][] = [];
|
||||
|
||||
// Add start stop coords if present
|
||||
if (selectedRoute.from_lat && selectedRoute.from_lng) {
|
||||
coords.push([selectedRoute.from_lat, selectedRoute.from_lng]);
|
||||
}
|
||||
|
||||
// Add all intermediary stop coords
|
||||
selectedRoute.stops.forEach(stop => {
|
||||
if (stop.lat && stop.lng) {
|
||||
coords.push([stop.lat, stop.lng]);
|
||||
}
|
||||
});
|
||||
|
||||
// Add end stop coords if present
|
||||
if (selectedRoute.to_lat && selectedRoute.to_lng) {
|
||||
coords.push([selectedRoute.to_lat, selectedRoute.to_lng]);
|
||||
}
|
||||
|
||||
return coords;
|
||||
}, [selectedRoute]);
|
||||
|
||||
// Determine map bounds
|
||||
const mapBounds = useMemo(() => {
|
||||
if (pathCoordinates.length === 0) return null;
|
||||
return pathCoordinates as L.LatLngBoundsExpression;
|
||||
}, [pathCoordinates]);
|
||||
|
||||
// Determine which routes to display (either the selected one, or all route start pins as overview)
|
||||
const renderMarkers = () => {
|
||||
if (selectedRoute) {
|
||||
return (
|
||||
<>
|
||||
{selectedRoute.stops.map((stop, index) => {
|
||||
const isStart = index === 0;
|
||||
const isEnd = index === selectedRoute.stops.length - 1;
|
||||
const isRit = stop.name.toLowerCase().includes("rit");
|
||||
|
||||
let currentIcon = stopIcon;
|
||||
if (isStart) currentIcon = startIcon;
|
||||
if (isRit || isEnd) currentIcon = campusIcon;
|
||||
|
||||
if (!stop.lat || !stop.lng) return null;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`${stop.name}-${index}`}
|
||||
position={[stop.lat, stop.lng]}
|
||||
icon={currentIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1 font-sans">
|
||||
<h4 className="font-bold text-slate-800 text-xs">{stop.name}</h4>
|
||||
<p className="text-[10px] text-orange-500 font-semibold mt-0.5">Estimated: {stop.time}</p>
|
||||
{isStart && <span className="inline-block text-[9px] bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded-full mt-1 font-bold">Departure Stop</span>}
|
||||
{isRit && <span className="inline-block text-[9px] bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded-full mt-1 font-bold">RIT Campus</span>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Default view: Draw RIT Campus pin + Start pins for all routes
|
||||
return (
|
||||
<>
|
||||
<Marker position={[13.0118, 80.0214]} icon={campusIcon}>
|
||||
<Popup>
|
||||
<div className="p-1 font-sans">
|
||||
<h4 className="font-bold text-slate-800 text-xs">Rajalakshmi Institute of Technology</h4>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">Kuthambakkam, Chennai</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{allRoutes.map((r, i) => {
|
||||
if (!r.from_lat || !r.from_lng) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={`start-${r.number}-${i}`}
|
||||
position={[r.from_lat, r.from_lng]}
|
||||
icon={startIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1 font-sans">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] text-white font-bold" style={{ backgroundColor: r.color }}>
|
||||
{r.number}
|
||||
</span>
|
||||
<h4 className="font-bold text-slate-800 text-xs mt-1">{r.name}</h4>
|
||||
<p className="text-[10px] text-slate-500">Starts: {r.from} at {r.departureTime}</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full min-h-[350px] bg-slate-950 rounded-2xl overflow-hidden border border-slate-200/80 shadow-md">
|
||||
<MapContainer
|
||||
center={DEFAULT_CENTER as L.LatLngExpression}
|
||||
zoom={11}
|
||||
className="h-full w-full z-10"
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{renderMarkers()}
|
||||
|
||||
{selectedRoute && pathCoordinates.length > 1 && (
|
||||
<Polyline
|
||||
positions={pathCoordinates}
|
||||
color={selectedRoute.color || "#F97316"}
|
||||
weight={4}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MapUpdater bounds={mapBounds} />
|
||||
</MapContainer>
|
||||
|
||||
{/* Floating Info Overlay on Selected Route */}
|
||||
{selectedRoute && (
|
||||
<div className="absolute bottom-4 left-4 z-20 bg-white/95 backdrop-blur-md px-4 py-3 rounded-2xl border border-slate-200 shadow-xl max-w-xs pointer-events-auto">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className="px-2.5 py-0.5 rounded-full text-xs font-bold text-white shadow-xs"
|
||||
style={{ backgroundColor: selectedRoute.color }}
|
||||
>
|
||||
{selectedRoute.number}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-800">{selectedRoute.name}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-600 space-y-1">
|
||||
<p>🏁 <strong>Start:</strong> {selectedRoute.from} ({selectedRoute.departureTime})</p>
|
||||
<p>🏫 <strong>Destination:</strong> RIT Campus ({selectedRoute.arrivalTime})</p>
|
||||
<p>📍 <strong>Total Stops:</strong> {selectedRoute.stops.length} mapped stops</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import SectionTitle from '@/components/SectionTitle/SectionTitle';
|
||||
import BusCard from '@/components/BusCard/BusCard';
|
||||
import BusRouteMap from '@/components/BusRouteMap/BusRouteMap';
|
||||
import FacultyCard from '@/components/FacultyCard/FacultyCard';
|
||||
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
|
||||
import { FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
|
||||
@@ -99,6 +100,7 @@ export default function Campus() {
|
||||
const [busSearch, setBusSearch] = useState('');
|
||||
const [busRoutes, setBusRoutes] = useState<BusRoute[]>([]);
|
||||
const [loadingBus, setLoadingBus] = useState(true);
|
||||
const [selectedBusRoute, setSelectedBusRoute] = useState<BusRoute | null>(null);
|
||||
|
||||
// Interactive Map State
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
@@ -757,13 +759,30 @@ export default function Campus() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{filteredRoutes.map((route) => (
|
||||
<StaggerItem key={route.id}>
|
||||
<BusCard route={route} />
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||
{/* Routes List */}
|
||||
<div className="lg:col-span-5 max-h-[650px] overflow-y-auto pr-2 space-y-4 scrollbar-thin">
|
||||
<StaggerContainer>
|
||||
{filteredRoutes.map((route) => (
|
||||
<StaggerItem key={route.id} className="mb-4">
|
||||
<BusCard
|
||||
route={route}
|
||||
isSelected={selectedBusRoute?.number === route.number}
|
||||
onSelect={() => setSelectedBusRoute(route)}
|
||||
/>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
</div>
|
||||
|
||||
{/* Interactive Map */}
|
||||
<div className="lg:col-span-7 h-[450px] lg:h-[650px] sticky top-24">
|
||||
<BusRouteMap
|
||||
selectedRoute={selectedBusRoute}
|
||||
allRoutes={filteredRoutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface Faculty {
|
||||
export interface BusStop {
|
||||
name: string;
|
||||
time: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
export interface BusRoute {
|
||||
@@ -63,6 +65,10 @@ export interface BusRoute {
|
||||
arrivalTime: string;
|
||||
stops: BusStop[];
|
||||
color: string;
|
||||
from_lat?: number;
|
||||
from_lng?: number;
|
||||
to_lat?: number;
|
||||
to_lng?: number;
|
||||
}
|
||||
|
||||
// ─── Notes / PYQs ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user