feat: implement interactive Leaflet bus route map and geocoding script
This commit is contained in:
51
package-lock.json
generated
51
package-lock.json
generated
@@ -29,16 +29,19 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.42.2",
|
"framer-motion": "^12.42.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.25.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-hook-form": "^7.82.0",
|
"react-hook-form": "^7.82.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -1488,6 +1491,17 @@
|
|||||||
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||||
@@ -2378,6 +2392,23 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
@@ -3025,6 +3056,12 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
@@ -3536,6 +3573,20 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|||||||
@@ -31,16 +31,19 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.42.2",
|
"framer-motion": "^12.42.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.25.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-hook-form": "^7.82.0",
|
"react-hook-form": "^7.82.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { BusRoute } from '@/types';
|
|||||||
|
|
||||||
interface BusCardProps {
|
interface BusCardProps {
|
||||||
route: BusRoute;
|
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);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -15,8 +17,14 @@ export default function BusCard({ route }: BusCardProps) {
|
|||||||
layout
|
layout
|
||||||
whileHover={{ y: -3 }}
|
whileHover={{ y: -3 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="bg-white border border-[#E5E7EB] rounded-2xl overflow-hidden"
|
onClick={onSelect}
|
||||||
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
|
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 */}
|
{/* Color Strip */}
|
||||||
<div className="h-1.5" style={{ backgroundColor: route.color }} />
|
<div className="h-1.5" style={{ backgroundColor: route.color }} />
|
||||||
@@ -71,7 +79,10 @@ export default function BusCard({ route }: BusCardProps) {
|
|||||||
|
|
||||||
{/* Toggle Stops */}
|
{/* Toggle Stops */}
|
||||||
<button
|
<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"
|
className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'Poppins, sans-serif',
|
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';
|
} from 'lucide-react';
|
||||||
import SectionTitle from '@/components/SectionTitle/SectionTitle';
|
import SectionTitle from '@/components/SectionTitle/SectionTitle';
|
||||||
import BusCard from '@/components/BusCard/BusCard';
|
import BusCard from '@/components/BusCard/BusCard';
|
||||||
|
import BusRouteMap from '@/components/BusRouteMap/BusRouteMap';
|
||||||
import FacultyCard from '@/components/FacultyCard/FacultyCard';
|
import FacultyCard from '@/components/FacultyCard/FacultyCard';
|
||||||
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
|
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
|
||||||
import { FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
|
import { FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
|
||||||
@@ -99,6 +100,7 @@ export default function Campus() {
|
|||||||
const [busSearch, setBusSearch] = useState('');
|
const [busSearch, setBusSearch] = useState('');
|
||||||
const [busRoutes, setBusRoutes] = useState<BusRoute[]>([]);
|
const [busRoutes, setBusRoutes] = useState<BusRoute[]>([]);
|
||||||
const [loadingBus, setLoadingBus] = useState(true);
|
const [loadingBus, setLoadingBus] = useState(true);
|
||||||
|
const [selectedBusRoute, setSelectedBusRoute] = useState<BusRoute | null>(null);
|
||||||
|
|
||||||
// Interactive Map State
|
// Interactive Map State
|
||||||
const [zoomLevel, setZoomLevel] = useState(1);
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
@@ -757,13 +759,30 @@ export default function Campus() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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) => (
|
{filteredRoutes.map((route) => (
|
||||||
<StaggerItem key={route.id}>
|
<StaggerItem key={route.id} className="mb-4">
|
||||||
<BusCard route={route} />
|
<BusCard
|
||||||
|
route={route}
|
||||||
|
isSelected={selectedBusRoute?.number === route.number}
|
||||||
|
onSelect={() => setSelectedBusRoute(route)}
|
||||||
|
/>
|
||||||
</StaggerItem>
|
</StaggerItem>
|
||||||
))}
|
))}
|
||||||
</StaggerContainer>
|
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface Faculty {
|
|||||||
export interface BusStop {
|
export interface BusStop {
|
||||||
name: string;
|
name: string;
|
||||||
time: string;
|
time: string;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BusRoute {
|
export interface BusRoute {
|
||||||
@@ -63,6 +65,10 @@ export interface BusRoute {
|
|||||||
arrivalTime: string;
|
arrivalTime: string;
|
||||||
stops: BusStop[];
|
stops: BusStop[];
|
||||||
color: string;
|
color: string;
|
||||||
|
from_lat?: number;
|
||||||
|
from_lng?: number;
|
||||||
|
to_lat?: number;
|
||||||
|
to_lng?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Notes / PYQs ────────────────────────────────────────────────────────────
|
// ─── Notes / PYQs ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
182
telegram-bot/coordinates_cache.json
Normal file
182
telegram-bot/coordinates_cache.json
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{
|
||||||
|
"7 H Bus Depot": {
|
||||||
|
"lat": 13.0347162,
|
||||||
|
"lng": 80.2054383
|
||||||
|
},
|
||||||
|
"Aazar Khana Bus Stop": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Adambakkam Bus Depot": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Adyar Depot": {
|
||||||
|
"lat": 12.9984686,
|
||||||
|
"lng": 80.2561546
|
||||||
|
},
|
||||||
|
"Agaram": {
|
||||||
|
"lat": 13.1158603,
|
||||||
|
"lng": 80.2300407
|
||||||
|
},
|
||||||
|
"Ajax": {
|
||||||
|
"lat": 13.1685,
|
||||||
|
"lng": 80.3005
|
||||||
|
},
|
||||||
|
"Ajeya Stadium": {
|
||||||
|
"lat": 13.1327901,
|
||||||
|
"lng": 80.1021537
|
||||||
|
},
|
||||||
|
"Alwar Thirunagar": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ambattur": {
|
||||||
|
"lat": 13.1196788,
|
||||||
|
"lng": 80.1496969
|
||||||
|
},
|
||||||
|
"Aminijkarai": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Amjikarai Market": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Amutha School": {
|
||||||
|
"lat": 13.0822304,
|
||||||
|
"lng": 80.1694593
|
||||||
|
},
|
||||||
|
"Anagaputhur": {
|
||||||
|
"lat": 12.974841,
|
||||||
|
"lng": 80.1270021
|
||||||
|
},
|
||||||
|
"Andarkuppam": {
|
||||||
|
"lat": 13.1938755,
|
||||||
|
"lng": 80.2685646
|
||||||
|
},
|
||||||
|
"Anna Arch": {
|
||||||
|
"lat": 13.075358,
|
||||||
|
"lng": 80.2176034
|
||||||
|
},
|
||||||
|
"Anna Nagar Metro": {
|
||||||
|
"lat": 13.0961458,
|
||||||
|
"lng": 80.1986901
|
||||||
|
},
|
||||||
|
"Anna Nagar Roundtana": {
|
||||||
|
"lat": 13.085,
|
||||||
|
"lng": 80.2101
|
||||||
|
},
|
||||||
|
"Anna Nagar West Depot": {
|
||||||
|
"lat": 13.0939332,
|
||||||
|
"lng": 80.1980855
|
||||||
|
},
|
||||||
|
"Apollo": {
|
||||||
|
"lat": 13.1189,
|
||||||
|
"lng": 80.2874
|
||||||
|
},
|
||||||
|
"Aranganathan Subway": {
|
||||||
|
"lat": 13.0282195,
|
||||||
|
"lng": 80.2252655
|
||||||
|
},
|
||||||
|
"Aranvoyal": {
|
||||||
|
"lat": 13.1024693,
|
||||||
|
"lng": 79.9540238
|
||||||
|
},
|
||||||
|
"Aravind Hospital": {
|
||||||
|
"lat": 13.0532913,
|
||||||
|
"lng": 80.1249542
|
||||||
|
},
|
||||||
|
"Arcot Bus Stand": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Arignar Anna Gov College": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Arul Nagar": {
|
||||||
|
"lat": 12.9542167,
|
||||||
|
"lng": 80.1933583
|
||||||
|
},
|
||||||
|
"Arumbakkam": {
|
||||||
|
"lat": 13.074371,
|
||||||
|
"lng": 80.2081312
|
||||||
|
},
|
||||||
|
"Arumbakkam (SBI Bank)": {
|
||||||
|
"lat": 13.074371,
|
||||||
|
"lng": 80.2081312
|
||||||
|
},
|
||||||
|
"Arumbakkam Panchaliamman Koil": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Arun Excello": {
|
||||||
|
"lat": 12.8173765,
|
||||||
|
"lng": 80.1626599
|
||||||
|
},
|
||||||
|
"Arunmathi theatre": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Aryagowda Road": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ashok Pillar": {
|
||||||
|
"lat": 13.0336,
|
||||||
|
"lng": 80.2114
|
||||||
|
},
|
||||||
|
"Avadi Checkpost": {
|
||||||
|
"lat": 13.1166,
|
||||||
|
"lng": 80.1013
|
||||||
|
},
|
||||||
|
"Avadi J.P.Garden": {
|
||||||
|
"lat": 13.1166,
|
||||||
|
"lng": 80.1013
|
||||||
|
},
|
||||||
|
"Avadi Ponnu Store": {
|
||||||
|
"lat": 13.1166,
|
||||||
|
"lng": 80.1013
|
||||||
|
},
|
||||||
|
"Avadi mankoil": {
|
||||||
|
"lat": 13.1166,
|
||||||
|
"lng": 80.1013
|
||||||
|
},
|
||||||
|
"Ayanawaram Noor Hotel": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ayanawaram Railway Quarters": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ayanawaram Signal": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ayappakkam Parking": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ayappakkam Petrol Bunk": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ayappakkam SBI ATM": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ayyampettai": {
|
||||||
|
"lat": 10.8962881,
|
||||||
|
"lng": 79.1886395
|
||||||
|
},
|
||||||
|
"Ayyangarkulam": {
|
||||||
|
"lat": 13.0118,
|
||||||
|
"lng": 80.0214
|
||||||
|
},
|
||||||
|
"Ayyapanthangal": {
|
||||||
|
"lat": 13.041,
|
||||||
|
"lng": 80.1412
|
||||||
|
}
|
||||||
|
}
|
||||||
189
telegram-bot/geocode_bus_routes.py
Normal file
189
telegram-bot/geocode_bus_routes.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
|
||||||
|
# File Paths
|
||||||
|
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BUS_ROUTES_PATH = os.path.abspath(os.path.join(CURRENT_DIR, "..", "backend", "src", "main", "resources", "bus_routes.json"))
|
||||||
|
CACHE_PATH = os.path.join(CURRENT_DIR, "coordinates_cache.json")
|
||||||
|
|
||||||
|
# Predefined coordinates for hard-to-geocode stops or fallback landmarks in Chennai
|
||||||
|
STATIC_COORDINATES = {
|
||||||
|
"RIT Campus": {"lat": 13.0118, "lng": 80.0214},
|
||||||
|
"Rajalakshmi Institute of Technology": {"lat": 13.0118, "lng": 80.0214},
|
||||||
|
"Kuthambakkam": {"lat": 13.0125, "lng": 80.0210},
|
||||||
|
"Lift Gate": {"lat": 13.1610, "lng": 80.3015},
|
||||||
|
"Wimco Market": {"lat": 13.1782, "lng": 80.3021},
|
||||||
|
"Ajax": {"lat": 13.1685, "lng": 80.3005},
|
||||||
|
"Theradi": {"lat": 13.1620, "lng": 80.2995},
|
||||||
|
"Apollo": {"lat": 13.1189, "lng": 80.2874},
|
||||||
|
"Mint": {"lat": 13.1075, "lng": 80.2785},
|
||||||
|
"Parry's": {"lat": 13.0898, "lng": 80.2882},
|
||||||
|
"Central": {"lat": 13.0827, "lng": 80.2707},
|
||||||
|
"Egmore": {"lat": 13.0792, "lng": 80.2598},
|
||||||
|
"Dasprakash": {"lat": 13.0811, "lng": 80.2524},
|
||||||
|
"Ice House Police Station": {"lat": 13.0520, "lng": 80.2762},
|
||||||
|
"Choolaimedu Subway": {"lat": 13.0645, "lng": 80.2224},
|
||||||
|
"Kellys Signal": {"lat": 13.0820, "lng": 80.2435},
|
||||||
|
"Anna Nagar Roundtana": {"lat": 13.0850, "lng": 80.2101},
|
||||||
|
"VR Mall": {"lat": 13.0722, "lng": 80.1989},
|
||||||
|
"Mogappair West depot": {"lat": 13.0805, "lng": 80.1654},
|
||||||
|
"Nolambur": {"lat": 13.0737, "lng": 80.1614},
|
||||||
|
"MGR University": {"lat": 13.0337, "lng": 80.2014},
|
||||||
|
"Guindy": {"lat": 13.0067, "lng": 80.2206},
|
||||||
|
"Butt Road": {"lat": 13.0084, "lng": 80.1987},
|
||||||
|
"Porur": {"lat": 13.0382, "lng": 80.1565},
|
||||||
|
"Ayyapanthangal": {"lat": 13.0410, "lng": 80.1412},
|
||||||
|
"Poonamallee": {"lat": 13.0473, "lng": 80.0945},
|
||||||
|
"Tambaram": {"lat": 12.9238, "lng": 80.1214},
|
||||||
|
"Avadi": {"lat": 13.1166, "lng": 80.1013},
|
||||||
|
"Chrompet": {"lat": 12.9610, "lng": 80.1462},
|
||||||
|
"Koyambedu": {"lat": 13.0728, "lng": 80.2019},
|
||||||
|
"Koyambedu Metro": {"lat": 13.0728, "lng": 80.2019},
|
||||||
|
"CMBT": {"lat": 13.0678, "lng": 80.2054},
|
||||||
|
"Aminjikarai": {"lat": 13.0734, "lng": 80.2195},
|
||||||
|
"Skywalk": {"lat": 13.0741, "lng": 80.2185},
|
||||||
|
"Maduravoyal": {"lat": 13.0673, "lng": 80.1627},
|
||||||
|
"Vanagaram": {"lat": 13.0636, "lng": 80.1548},
|
||||||
|
"Kasi Theatre": {"lat": 13.0305, "lng": 80.2132},
|
||||||
|
"Ashok Pillar": {"lat": 13.0336, "lng": 80.2114},
|
||||||
|
"Olympia": {"lat": 13.0135, "lng": 80.2155},
|
||||||
|
"Mandaveli": {"lat": 13.0238, "lng": 80.2687},
|
||||||
|
"Santhome": {"lat": 13.0336, "lng": 80.2785},
|
||||||
|
"Pattinapakkam": {"lat": 13.0287, "lng": 80.2788},
|
||||||
|
"Kovilampakkam": {"lat": 12.9510, "lng": 80.1874},
|
||||||
|
"Keelkattalai": {"lat": 12.9687, "lng": 80.2014},
|
||||||
|
"Madipakkam": {"lat": 12.9738, "lng": 80.2087},
|
||||||
|
"Nanganallur": {"lat": 12.9838, "lng": 80.1987},
|
||||||
|
"Saidapet": {"lat": 13.0238, "lng": 80.2287},
|
||||||
|
"KK Nagar": {"lat": 13.0373, "lng": 80.2045},
|
||||||
|
"Virugampakkam": {"lat": 13.0487, "lng": 80.1914},
|
||||||
|
"Ramapuram": {"lat": 13.0287, "lng": 80.1788},
|
||||||
|
"Tiruvallur": {"lat": 13.1438, "lng": 79.9087},
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_cache():
|
||||||
|
if os.path.exists(CACHE_PATH):
|
||||||
|
try:
|
||||||
|
with open(CACHE_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_cache(cache):
|
||||||
|
with open(CACHE_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cache, f, indent=2)
|
||||||
|
|
||||||
|
def clean_name(name):
|
||||||
|
# Remove contents inside parentheses
|
||||||
|
name = re.sub(r'\(.*?\)', '', name)
|
||||||
|
# Remove descriptors like 'Route', 'Stop', 'Signal', 'Bunk', etc.
|
||||||
|
name = re.sub(r'\b(stop|signal|bunk|junction|crossing|stage|bus stop|market|railway station|station)\b', '', name, flags=re.IGNORECASE)
|
||||||
|
# Normalize whitespaces
|
||||||
|
return " ".join(name.split()).strip()
|
||||||
|
|
||||||
|
def geocode_nominatim(name):
|
||||||
|
# 1. Check static overrides first
|
||||||
|
cleaned = clean_name(name)
|
||||||
|
for key, coords in STATIC_COORDINATES.items():
|
||||||
|
if key.lower() == name.lower() or key.lower() == cleaned.lower():
|
||||||
|
return coords
|
||||||
|
|
||||||
|
# 2. Try clean geocoding
|
||||||
|
search_queries = [
|
||||||
|
f"{name}, Chennai, Tamil Nadu, India",
|
||||||
|
f"{cleaned}, Chennai, Tamil Nadu, India",
|
||||||
|
f"{cleaned}, Tamil Nadu, India"
|
||||||
|
]
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "RITFreshersHubBusGeocoderConsolidator/2.0 (mail@ritchennai.edu.in)"
|
||||||
|
}
|
||||||
|
|
||||||
|
for query in search_queries:
|
||||||
|
try:
|
||||||
|
url = "https://nominatim.openstreetmap.org/search"
|
||||||
|
params = {"q": query, "format": "json", "limit": 1}
|
||||||
|
print(f"Requesting: {query} ...")
|
||||||
|
res = requests.get(url, headers=headers, params=params, timeout=10)
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
if data:
|
||||||
|
lat = float(data[0]["lat"])
|
||||||
|
lng = float(data[0]["lon"])
|
||||||
|
print(f"-> Found: {lat}, {lng}")
|
||||||
|
return {"lat": lat, "lng": lng}
|
||||||
|
# Rest to respect API limit
|
||||||
|
time.sleep(1.2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error querying {query}: {e}")
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
# 3. Fallback keywords check
|
||||||
|
for key, coords in STATIC_COORDINATES.items():
|
||||||
|
if key.lower() in name.lower() or key.lower() in cleaned.lower():
|
||||||
|
print(f"-> Matching fallback keyword: '{key}' for '{name}'")
|
||||||
|
return coords
|
||||||
|
|
||||||
|
# Default fallback: RIT Campus coordinates
|
||||||
|
print(f"-> No match found for '{name}'. Defaulting to RIT Campus.")
|
||||||
|
return STATIC_COORDINATES["RIT Campus"]
|
||||||
|
|
||||||
|
def geocode_routes():
|
||||||
|
if not os.path.exists(BUS_ROUTES_PATH):
|
||||||
|
print(f"Error: {BUS_ROUTES_PATH} does not exist.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(BUS_ROUTES_PATH, "r", encoding="utf-8") as f:
|
||||||
|
routes = json.load(f)
|
||||||
|
|
||||||
|
cache = load_cache()
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
# Collect all unique stop names
|
||||||
|
unique_names = set()
|
||||||
|
for r in routes:
|
||||||
|
unique_names.add(r["from"])
|
||||||
|
unique_names.add(r["to"])
|
||||||
|
for stop in r.get("stops", []):
|
||||||
|
unique_names.add(stop["name"])
|
||||||
|
|
||||||
|
print(f"Total unique stops to geocode: {len(unique_names)}")
|
||||||
|
|
||||||
|
# Geocode each stop
|
||||||
|
count = 0
|
||||||
|
for name in sorted(unique_names):
|
||||||
|
if name not in cache:
|
||||||
|
coords = geocode_nominatim(name)
|
||||||
|
cache[name] = coords
|
||||||
|
count += 1
|
||||||
|
# Save cache incrementally
|
||||||
|
if count % 5 == 0:
|
||||||
|
save_cache(cache)
|
||||||
|
save_cache(cache)
|
||||||
|
|
||||||
|
# Enrich bus_routes.json
|
||||||
|
for r in routes:
|
||||||
|
from_coords = cache.get(r["from"], STATIC_COORDINATES["RIT Campus"])
|
||||||
|
to_coords = cache.get(r["to"], STATIC_COORDINATES["RIT Campus"])
|
||||||
|
r["from_lat"] = from_coords["lat"]
|
||||||
|
r["from_lng"] = from_coords["lng"]
|
||||||
|
r["to_lat"] = to_coords["lat"]
|
||||||
|
r["to_lng"] = to_coords["lng"]
|
||||||
|
|
||||||
|
for stop in r.get("stops", []):
|
||||||
|
stop_coords = cache.get(stop["name"], STATIC_COORDINATES["RIT Campus"])
|
||||||
|
stop["lat"] = stop_coords["lat"]
|
||||||
|
stop["lng"] = stop_coords["lng"]
|
||||||
|
|
||||||
|
# Write enriched data back
|
||||||
|
with open(BUS_ROUTES_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(routes, f, indent=2)
|
||||||
|
|
||||||
|
print("Geocoding process complete! bus_routes.json updated with coordinates.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
geocode_routes()
|
||||||
Reference in New Issue
Block a user