feat: Add native Android driver tracking app and real-time backend/frontend bus tracking system
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } 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';
|
||||
import { getBackendUrl } from '@/lib/utils';
|
||||
|
||||
// Standard Leaflet Icon fix for Vite
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
@@ -27,6 +28,24 @@ const isValidCoordinate = (lat?: number, lng?: number): boolean => {
|
||||
return lat >= MIN_LAT && lat <= MAX_LAT && lng >= MIN_LNG && lng <= MAX_LNG;
|
||||
};
|
||||
|
||||
const isRealStopCoordinate = (stop: { name: string; lat?: number; lng?: number }): boolean => {
|
||||
if (stop.lat === undefined || stop.lng === undefined) return false;
|
||||
if (!isValidCoordinate(stop.lat, stop.lng)) return false;
|
||||
|
||||
// Check for RIT Campus coordinate fallback (13.0118, 80.0214)
|
||||
const isRITCoord = Math.abs(stop.lat - 13.0118) < 0.001 && Math.abs(stop.lng - 80.0214) < 0.001;
|
||||
const isRITName = stop.name.toLowerCase().includes("rit") ||
|
||||
stop.name.toLowerCase().includes("campus") ||
|
||||
stop.name.toLowerCase().includes("college") ||
|
||||
stop.name.toLowerCase().includes("rajalakshmi");
|
||||
|
||||
if (isRITCoord && !isRITName) {
|
||||
return false; // Exclude defaulted RIT Campus coordinates
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Custom Premium DivIcons using Tailwind CSS
|
||||
const getStartIcon = (label: string = "1") => 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-extrabold hover:scale-110 transition-transform">${label}</div>`,
|
||||
@@ -49,6 +68,13 @@ const campusIcon = L.divIcon({
|
||||
iconAnchor: [18, 18],
|
||||
});
|
||||
|
||||
const liveBusIcon = L.divIcon({
|
||||
html: `<div class="relative flex items-center justify-center w-9 h-9 rounded-full bg-emerald-500 border-2 border-white shadow-lg text-white text-sm font-bold"><span class="absolute inset-0 rounded-full bg-emerald-500/40 animate-ping"></span>🚌</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();
|
||||
@@ -68,27 +94,76 @@ interface BusRouteMapProps {
|
||||
const DEFAULT_CENTER = [13.0118, 80.0214]; // RIT Campus default
|
||||
|
||||
export default function BusRouteMap({ selectedRoute, allRoutes }: BusRouteMapProps) {
|
||||
const [allLiveLocations, setAllLiveLocations] = useState<Record<String, { latitude: number; longitude: number }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLiveLocations = () => {
|
||||
fetch(getBackendUrl('/api/bus-locations'))
|
||||
.then((res) => {
|
||||
if (res.ok) return res.json();
|
||||
return [];
|
||||
})
|
||||
.then((data: Array<{ routeNumber: string; latitude: number; longitude: number }>) => {
|
||||
if (Array.isArray(data)) {
|
||||
const locMap: Record<string, { latitude: number; longitude: number }> = {};
|
||||
data.forEach((item) => {
|
||||
if (item.routeNumber && item.latitude && item.longitude) {
|
||||
locMap[item.routeNumber] = { latitude: item.latitude, longitude: item.longitude };
|
||||
}
|
||||
});
|
||||
setAllLiveLocations(locMap);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAllLiveLocations({});
|
||||
});
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchLiveLocations();
|
||||
|
||||
// Poll every 3 seconds
|
||||
const interval = setInterval(fetchLiveLocations, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
// Collect coordinates for the polyline path, filtering out any invalid outliers
|
||||
const pathCoordinates = useMemo(() => {
|
||||
if (!selectedRoute) return [];
|
||||
|
||||
const coords: [number, number][] = [];
|
||||
|
||||
// Add start stop coords if valid
|
||||
if (isValidCoordinate(selectedRoute.from_lat, selectedRoute.from_lng)) {
|
||||
coords.push([selectedRoute.from_lat!, selectedRoute.from_lng!]);
|
||||
// If route has pre-scraped polyline road path, use it!
|
||||
if (selectedRoute.polyline && selectedRoute.polyline.length > 0) {
|
||||
return selectedRoute.polyline;
|
||||
}
|
||||
|
||||
// Add all intermediary stop coords that are valid
|
||||
const coords: [number, number][] = [];
|
||||
|
||||
// Add start stop coords if valid and real
|
||||
if (isValidCoordinate(selectedRoute.from_lat, selectedRoute.from_lng)) {
|
||||
const isStartRIT = Math.abs(selectedRoute.from_lat! - 13.0118) < 0.001 && Math.abs(selectedRoute.from_lng! - 80.0214) < 0.001;
|
||||
const isStartRITName = selectedRoute.from.toLowerCase().includes("rit") ||
|
||||
selectedRoute.from.toLowerCase().includes("campus") ||
|
||||
selectedRoute.from.toLowerCase().includes("college");
|
||||
if (!isStartRIT || isStartRITName) {
|
||||
coords.push([selectedRoute.from_lat!, selectedRoute.from_lng!]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all intermediary stop coords that are valid and real
|
||||
selectedRoute.stops.forEach(stop => {
|
||||
if (isValidCoordinate(stop.lat, stop.lng)) {
|
||||
if (isRealStopCoordinate(stop)) {
|
||||
coords.push([stop.lat!, stop.lng!]);
|
||||
}
|
||||
});
|
||||
|
||||
// Add end stop coords if valid
|
||||
// Add end stop coords if valid and real
|
||||
if (isValidCoordinate(selectedRoute.to_lat, selectedRoute.to_lng)) {
|
||||
coords.push([selectedRoute.to_lat!, selectedRoute.to_lng!]);
|
||||
const isEndRIT = Math.abs(selectedRoute.to_lat! - 13.0118) < 0.001 && Math.abs(selectedRoute.to_lng! - 80.0214) < 0.001;
|
||||
const isEndRITName = selectedRoute.to.toLowerCase().includes("rit") ||
|
||||
selectedRoute.to.toLowerCase().includes("campus") ||
|
||||
selectedRoute.to.toLowerCase().includes("college");
|
||||
if (!isEndRIT || isEndRITName) {
|
||||
coords.push([selectedRoute.to_lat!, selectedRoute.to_lng!]);
|
||||
}
|
||||
}
|
||||
|
||||
return coords;
|
||||
@@ -103,8 +178,8 @@ export default function BusRouteMap({ selectedRoute, allRoutes }: BusRouteMapPro
|
||||
// Determine which markers to display
|
||||
const renderMarkers = () => {
|
||||
if (selectedRoute) {
|
||||
// Filter out stops that do not have valid coordinates
|
||||
const validStops = selectedRoute.stops.filter(stop => isValidCoordinate(stop.lat, stop.lng));
|
||||
// Filter out stops that do not have valid/real coordinates
|
||||
const validStops = selectedRoute.stops.filter(stop => isRealStopCoordinate(stop));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -192,6 +267,27 @@ export default function BusRouteMap({ selectedRoute, allRoutes }: BusRouteMapPro
|
||||
/>
|
||||
|
||||
{renderMarkers()}
|
||||
|
||||
{Object.entries(allLiveLocations).map(([rNum, loc]) => {
|
||||
if (selectedRoute && selectedRoute.number !== rNum) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={`live-bus-${rNum}`}
|
||||
position={[loc.latitude, loc.longitude]}
|
||||
icon={liveBusIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1 font-sans text-center">
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-[10px] font-bold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>LIVE TRACKING
|
||||
</span>
|
||||
<h4 className="font-bold text-slate-800 text-xs mt-1">Bus {rNum}</h4>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">Broadcasting live coordinates</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedRoute && pathCoordinates.length > 1 && (
|
||||
<Polyline
|
||||
|
||||
@@ -20,3 +20,13 @@ export function formatTime(timeStr: string): string {
|
||||
export function truncate(str: string, n: number): string {
|
||||
return str.length > n ? str.substring(0, n - 1) + '…' : str;
|
||||
}
|
||||
|
||||
export const getBackendUrl = (path: string = ''): string => {
|
||||
const host = window.location.hostname;
|
||||
return `http://${host}:8085${path}`;
|
||||
};
|
||||
|
||||
export const getChatbotUrl = (path: string = ''): string => {
|
||||
const host = window.location.hostname;
|
||||
return `http://${host}:8081${path}`;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Bot, Send, Sparkles, RefreshCw, Mic, X, ChevronRight } from 'lucide-react';
|
||||
import { AI_SUGGESTED_PROMPTS } from '@/constants';
|
||||
import type { ChatMessage } from '@/types';
|
||||
import { getChatbotUrl } from '@/lib/utils';
|
||||
|
||||
const INITIAL_MESSAGES: ChatMessage[] = [
|
||||
{
|
||||
@@ -44,7 +45,7 @@ export default function AIAssistant() {
|
||||
setIsTyping(true); // Call the Go chatbot microservice
|
||||
let responseText = '';
|
||||
try {
|
||||
const res = await fetch('http://localhost:8081/api/chat', {
|
||||
const res = await fetch(getChatbotUrl('/api/chat'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text }),
|
||||
@@ -72,7 +73,7 @@ export default function AIAssistant() {
|
||||
} else if (lower.includes('library')) {
|
||||
responseText = responses.library;
|
||||
} else {
|
||||
responseText = `Thank you for your question about **"${text}"**!\n\nI couldn't reach the chatbot API server. Please make sure the Go service is running on http://localhost:8081.`;
|
||||
responseText = `Thank you for your question about **"${text}"**!\n\nI couldn't reach the chatbot API server. Please make sure the Go service is running on ${getChatbotUrl()}.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import type { BusRoute } from '@/types';
|
||||
import { getBackendUrl } from '@/lib/utils';
|
||||
|
||||
interface LocationSpot {
|
||||
name: string;
|
||||
@@ -101,6 +102,7 @@ export default function Campus() {
|
||||
const [busRoutes, setBusRoutes] = useState<BusRoute[]>([]);
|
||||
const [loadingBus, setLoadingBus] = useState(true);
|
||||
const [selectedBusRoute, setSelectedBusRoute] = useState<BusRoute | null>(null);
|
||||
const [hasFetchedBus, setHasFetchedBus] = useState(false);
|
||||
|
||||
// Interactive Map State
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
@@ -178,23 +180,38 @@ export default function Campus() {
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'bus' && busRoutes.length === 0) {
|
||||
if (activeTab === 'bus' && !hasFetchedBus) {
|
||||
setLoadingBus(true);
|
||||
fetch('http://localhost:8080/api/bus-routes')
|
||||
fetch(getBackendUrl('/api/bus-routes'))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
if (!res.ok) throw new Error('Backend offline');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setBusRoutes(data);
|
||||
if (data && data.length > 0) {
|
||||
setBusRoutes(data);
|
||||
setSelectedBusRoute(prev => prev || data[0]);
|
||||
} else {
|
||||
throw new Error('Empty data');
|
||||
}
|
||||
setLoadingBus(false);
|
||||
setHasFetchedBus(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error fetching bus routes:', err);
|
||||
setLoadingBus(false);
|
||||
.catch(() => {
|
||||
fetch('/bus_routes.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setBusRoutes(data);
|
||||
setSelectedBusRoute(prev => prev || data[0]);
|
||||
})
|
||||
.catch((err) => console.error('Fallback fetch error:', err))
|
||||
.finally(() => {
|
||||
setLoadingBus(false);
|
||||
setHasFetchedBus(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [activeTab, busRoutes.length]);
|
||||
}, [activeTab, hasFetchedBus]);
|
||||
|
||||
const filteredAndSortedFaculty = useMemo(() => {
|
||||
const filtered = FACULTY_DATA.filter((f) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Shield, Lock, Smile, ChevronRight, Search, Plus, User
|
||||
} from 'lucide-react';
|
||||
import SectionTitle from '@/components/SectionTitle/SectionTitle';
|
||||
import { getBackendUrl } from '@/lib/utils';
|
||||
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
|
||||
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
|
||||
import { CONFESSIONS_DATA } from '@/constants';
|
||||
@@ -91,7 +92,7 @@ export default function Community() {
|
||||
const fetchQuestions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8080/api/questions`);
|
||||
const response = await fetch(getBackendUrl('/api/questions'));
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Backend returns oldest-first, reverse to show newest first
|
||||
@@ -193,7 +194,7 @@ export default function Community() {
|
||||
|
||||
try {
|
||||
if (isLikedNow) {
|
||||
await fetch(`http://localhost:8080/api/questions/${id}/upvote`, {
|
||||
await fetch(getBackendUrl(`/api/questions/${id}/upvote`), {
|
||||
method: 'POST'
|
||||
});
|
||||
fetchQuestions();
|
||||
@@ -240,7 +241,7 @@ export default function Community() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/questions', {
|
||||
const response = await fetch(getBackendUrl('/api/questions'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Download, Filter, BookOpen, FileText, ScrollText, BookMarked, ChevronRight, ArrowLeft, ExternalLink, Code, Globe, GitBranch, Cloud, Palette, Shield, DollarSign, BarChart3, Users, Megaphone, TrendingUp, UserCheck, ClipboardList, ShoppingCart, Cpu, Activity, LayoutGrid, Layers, Zap, Settings, Gauge, Radio, Wifi, Bot, Sun, Wrench, Binary, BatteryCharging, CircuitBoard, ShieldCheck, BrainCircuit, FlaskConical, Lock, Terminal, Dna, Microscope, Sparkles, Smartphone, Monitor, Server, CheckCircle2, Database, Briefcase } from 'lucide-react';
|
||||
import SectionTitle from '@/components/SectionTitle/SectionTitle';
|
||||
import { getBackendUrl } from '@/lib/utils';
|
||||
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
|
||||
import { TOOLKIT_ITEMS, DEPARTMENTS } from '@/constants';
|
||||
|
||||
@@ -1332,7 +1333,7 @@ export default function Notes() {
|
||||
useEffect(() => {
|
||||
const fetchNotes = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/notes');
|
||||
const response = await fetch(getBackendUrl('/api/notes'));
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const mappedData = data.map((n: any) => ({
|
||||
@@ -1361,7 +1362,7 @@ export default function Notes() {
|
||||
|
||||
const handleDownload = async (noteId: string, downloadUrl: string) => {
|
||||
try {
|
||||
await fetch(`http://localhost:8080/api/notes/${noteId}/download`, { method: 'POST' });
|
||||
await fetch(getBackendUrl(`/api/notes/${noteId}/download`), { method: 'POST' });
|
||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, downloads: n.downloads + 1 } : n));
|
||||
} catch (err) {
|
||||
console.error('Error incrementing download count:', err);
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface BusRoute {
|
||||
from_lng?: number;
|
||||
to_lat?: number;
|
||||
to_lng?: number;
|
||||
polyline?: [number, number][];
|
||||
}
|
||||
|
||||
// ─── Notes / PYQs ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user