From 229518778855a99b9a67df570369048728eabe87 Mon Sep 17 00:00:00 2001
From: Shanmuga Krishnan S M
Date: Fri, 24 Jul 2026 09:27:40 +0530
Subject: [PATCH] feat: implement interactive Leaflet bus route map and
geocoding script
---
package-lock.json | 51 +++++
package.json | 3 +
src/components/BusCard/BusCard.tsx | 19 +-
src/components/BusRouteMap/BusRouteMap.tsx | 212 +++++++++++++++++++++
src/pages/Campus/Campus.tsx | 33 +++-
src/types/index.ts | 6 +
telegram-bot/coordinates_cache.json | 182 ++++++++++++++++++
telegram-bot/geocode_bus_routes.py | 189 ++++++++++++++++++
8 files changed, 684 insertions(+), 11 deletions(-)
create mode 100644 src/components/BusRouteMap/BusRouteMap.tsx
create mode 100644 telegram-bot/coordinates_cache.json
create mode 100644 telegram-bot/geocode_bus_routes.py
diff --git a/package-lock.json b/package-lock.json
index 524f51f..83055d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,16 +29,19 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
+ "leaflet": "^1.9.4",
"lucide-react": "^1.25.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.82.0",
+ "react-leaflet": "^5.0.0",
"react-router-dom": "^7.18.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@@ -1488,6 +1491,17 @@
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"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": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -2378,6 +2392,23 @@
"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": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -3025,6 +3056,12 @@
"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": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -3536,6 +3573,20 @@
"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": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
diff --git a/package.json b/package.json
index ca451da..45f4392 100644
--- a/package.json
+++ b/package.json
@@ -31,16 +31,19 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
+ "leaflet": "^1.9.4",
"lucide-react": "^1.25.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.82.0",
+ "react-leaflet": "^5.0.0",
"react-router-dom": "^7.18.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
diff --git a/src/components/BusCard/BusCard.tsx b/src/components/BusCard/BusCard.tsx
index f44453e..e0511d9 100644
--- a/src/components/BusCard/BusCard.tsx
+++ b/src/components/BusCard/BusCard.tsx
@@ -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 */}
@@ -71,7 +79,10 @@ export default function BusCard({ route }: BusCardProps) {
{/* Toggle Stops */}
) : (
-
- {filteredRoutes.map((route) => (
-
-
-
- ))}
-
+
+ {/* Routes List */}
+
+
+ {filteredRoutes.map((route) => (
+
+ setSelectedBusRoute(route)}
+ />
+
+ ))}
+
+
+
+ {/* Interactive Map */}
+
+
+
+
)}
)}
diff --git a/src/types/index.ts b/src/types/index.ts
index 70ceadf..7b8388e 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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 ────────────────────────────────────────────────────────────
diff --git a/telegram-bot/coordinates_cache.json b/telegram-bot/coordinates_cache.json
new file mode 100644
index 0000000..2d0d88f
--- /dev/null
+++ b/telegram-bot/coordinates_cache.json
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/telegram-bot/geocode_bus_routes.py b/telegram-bot/geocode_bus_routes.py
new file mode 100644
index 0000000..2c0d72c
--- /dev/null
+++ b/telegram-bot/geocode_bus_routes.py
@@ -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()