Files
openjsp/public/index.html

624 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skopje Bus Tracker</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 28px;
margin-bottom: 5px;
}
.header .subtitle {
opacity: 0.9;
font-size: 14px;
}
.controls {
background: white;
padding: 15px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 12px;
color: #666;
font-weight: 600;
}
.control-group input,
.control-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 250px;
}
.control-group input:focus,
.control-group select:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 8px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
margin-top: 20px;
}
.btn:hover {
background: #5568d3;
}
.container {
display: flex;
height: calc(100vh - 180px);
}
.sidebar {
width: 350px;
background: white;
padding: 20px;
overflow-y: auto;
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
}
.map-container {
flex: 1;
position: relative;
}
#map {
width: 100%;
height: 100%;
}
.info-card {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.info-card p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
.arrivals-section {
margin-top: 20px;
}
.arrivals-section h2 {
color: #333;
font-size: 18px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.arrival-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
transition: all 0.3s;
}
.arrival-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.arrival-item.approaching {
border-left: 4px solid #ff6b6b;
background: #fff5f5;
}
.arrival-item.soon {
border-left: 4px solid #ffa500;
background: #fff8e1;
}
.arrival-time {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.arrival-minutes {
color: #667eea;
font-weight: 600;
font-size: 16px;
}
.arrival-details {
font-size: 13px;
color: #666;
margin-top: 8px;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
margin-right: 5px;
}
.badge-live {
background: #4caf50;
color: white;
}
.badge-scheduled {
background: #9e9e9e;
color: white;
}
.badge-late {
background: #ff6b6b;
color: white;
}
.badge-early {
background: #2196f3;
color: white;
}
.badge-ontime {
background: #4caf50;
color: white;
}
.no-buses {
text-align: center;
padding: 30px;
color: #999;
}
.loading {
text-align: center;
padding: 20px;
color: #667eea;
}
.last-update {
text-align: center;
color: #999;
font-size: 12px;
padding: 10px;
border-top: 1px solid #eee;
margin-top: 10px;
}
.stats {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="header">
<h1>Skopje Bus Tracker</h1>
<div class="subtitle" id="routeInfo">Select stop and route to track</div>
</div>
<div class="controls">
<div class="control-group">
<label for="stopId">Bus Stop</label>
<select id="stopId">
<option value="">Loading stops...</option>
</select>
</div>
<div class="control-group">
<label for="routeId">Bus Route</label>
<select id="routeId">
<option value="">Loading routes...</option>
</select>
</div>
<button class="btn" onclick="loadTracker()">Track Bus</button>
</div>
<div class="container">
<div class="sidebar">
<div class="info-card">
<h3>Target Stop</h3>
<p id="stopName">Loading...</p>
<p id="stopCode"></p>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="busCount">-</div>
<div class="stat-label">Active Buses</div>
</div>
<div class="stat-item">
<div class="stat-value" id="arrivalCount">-</div>
<div class="stat-label">Upcoming</div>
</div>
</div>
<div class="arrivals-section">
<h2>Upcoming Arrivals</h2>
<div id="arrivalsList" class="loading">
Loading arrivals...
</div>
</div>
<div class="last-update" id="lastUpdate">
Last updated: -
</div>
</div>
<div class="map-container">
<div id="map"></div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let stopMarker;
let vehicleMarkers = [];
let config = null;
let currentStopId = null;
let currentRouteId = null;
let refreshInterval = null;
// Get URL parameters
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
stopId: params.get('stopId'),
routeId: params.get('routeId')
};
}
// Update URL without reload
function updateUrl(stopId, routeId) {
const url = new URL(window.location);
url.searchParams.set('stopId', stopId);
url.searchParams.set('routeId', routeId);
window.history.pushState({}, '', url);
}
// Initialize map
async function initMap() {
if (map) {
map.remove();
}
// Get configuration with selected stop and route
const queryParams = currentStopId && currentRouteId
? `?stopId=${currentStopId}&routeId=${currentRouteId}`
: '';
const response = await fetch('/api/config' + queryParams);
config = await response.json();
// Update header
document.getElementById('routeInfo').textContent =
`${config.route.shortName} - ${config.route.longName} at ${config.stop.name}`;
// Update sidebar info
document.getElementById('stopName').textContent = config.stop.name;
document.getElementById('stopCode').textContent = `Stop Code: ${config.stop.code}`;
// Initialize map centered on stop
map = L.map('map').setView([config.stop.lat, config.stop.lon], 14);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// Add stop marker
const stopIcon = L.divIcon({
html: '<div style="background: #667eea; width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.3);"></div>',
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
stopMarker = L.marker([config.stop.lat, config.stop.lon], { icon: stopIcon })
.addTo(map)
.bindPopup(`<b>${config.stop.name}</b><br>Stop Code: ${config.stop.code}`);
}
// Update vehicle positions
async function updateVehicles() {
if (!currentRouteId) return;
try {
const response = await fetch(`/api/vehicles?routeId=${currentRouteId}`);
const vehicles = await response.json();
document.getElementById('busCount').textContent = vehicles.length;
// Remove old markers
vehicleMarkers.forEach(marker => map.removeLayer(marker));
vehicleMarkers = [];
// Add new markers
vehicles.forEach(vehicle => {
const label = vehicle.label || vehicle.vehicleId || 'Bus';
const speedKmh = vehicle.speed ? (vehicle.speed * 3.6).toFixed(1) : '0.0'; // Convert m/s to km/h
// Create arrow icon rotated by bearing
const busIcon = L.divIcon({
html: `<div style="transform: rotate(${vehicle.bearing}deg); width: 30px; height: 30px;">
<svg width="30" height="30" viewBox="0 0 30 30">
<path d="M15 5 L25 25 L15 20 L5 25 Z" fill="#ff6b6b" stroke="white" stroke-width="2"/>
</svg>
</div>`,
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([vehicle.lat, vehicle.lon], { icon: busIcon })
.addTo(map)
.bindPopup(`
<b>Bus ${label}</b><br>
Vehicle ID: ${vehicle.vehicleId}<br>
Speed: ${speedKmh} km/h<br>
Bearing: ${vehicle.bearing}°<br>
Status: ${vehicle.currentStatus}<br>
Updated: ${new Date(vehicle.timestamp).toLocaleTimeString()}
`);
vehicleMarkers.push(marker);
});
} catch (error) {
console.error('Failed to update vehicles:', error);
}
}
// Update arrivals
async function updateArrivals() {
if (!currentStopId || !currentRouteId) return;
try {
const response = await fetch(`/api/arrivals?stopId=${currentStopId}&routeId=${currentRouteId}`);
const arrivals = await response.json();
const arrivalsList = document.getElementById('arrivalsList');
document.getElementById('arrivalCount').textContent = arrivals.length;
if (arrivals.length === 0) {
arrivalsList.innerHTML = '<div class="no-buses">No buses scheduled in the next 90 minutes</div>';
return;
}
arrivalsList.innerHTML = arrivals.slice(0, 10).map((arrival, index) => {
const arrivalTime = new Date(arrival.arrivalTime);
const delayMin = Math.floor(arrival.delaySeconds / 60);
let delayBadge = '';
if (delayMin > 2) {
delayBadge = `<span class="badge badge-late">${delayMin}min late</span>`;
} else if (delayMin < -2) {
delayBadge = `<span class="badge badge-early">${Math.abs(delayMin)}min early</span>`;
} else {
delayBadge = `<span class="badge badge-ontime">On time</span>`;
}
const realtimeBadge = arrival.isRealtime
? '<span class="badge badge-live">LIVE</span>'
: '<span class="badge badge-scheduled">SCHEDULED</span>';
let cssClass = 'arrival-item';
if (arrival.minutesUntil <= 2) {
cssClass += ' approaching';
} else if (arrival.minutesUntil <= 10) {
cssClass += ' soon';
}
const minutesText = arrival.minutesUntil <= 0
? 'ARRIVING NOW'
: `in ${arrival.minutesUntil} min`;
return `
<div class="${cssClass}">
<div class="arrival-time">${arrivalTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}</div>
<div class="arrival-minutes">${minutesText}</div>
<div class="arrival-details">
${realtimeBadge}
${delayBadge}
<br>
Direction: ${arrival.headsign}
</div>
</div>
`;
}).join('');
document.getElementById('lastUpdate').textContent =
`Last updated: ${new Date().toLocaleTimeString()}`;
} catch (error) {
console.error('Failed to update arrivals:', error);
document.getElementById('arrivalsList').innerHTML =
'<div class="no-buses">Failed to load arrivals</div>';
}
}
// Load tracker for selected stop and route
async function loadTracker() {
const stopId = document.getElementById('stopId').value.trim();
const routeId = document.getElementById('routeId').value.trim();
if (!stopId || !routeId) {
alert('Please select both a stop and a route');
return;
}
// Clear existing interval
if (refreshInterval) {
clearInterval(refreshInterval);
}
// Update current selection
currentStopId = stopId;
currentRouteId = routeId;
// Update URL
updateUrl(stopId, routeId);
// Initialize tracker
document.getElementById('arrivalsList').innerHTML = '<div class="loading">Loading arrivals...</div>';
try {
await initMap();
await updateVehicles();
await updateArrivals();
// Refresh data every 5 seconds
refreshInterval = setInterval(async () => {
await updateVehicles();
await updateArrivals();
}, 5000);
} catch (error) {
console.error('Failed to load tracker:', error);
alert('Failed to load tracker. Please try another stop or route.');
}
}
// Load stops and routes
async function loadStopsAndRoutes() {
try {
// Load stops
const stopsResponse = await fetch('/api/stops');
const stops = await stopsResponse.json();
const stopSelect = document.getElementById('stopId');
stops.sort((a, b) => a.name.localeCompare(b.name));
stopSelect.innerHTML = '<option value="">Select a stop...</option>' +
stops.map(stop =>
`<option value="${stop.id}">${stop.name} (${stop.code})</option>`
).join('');
// Load routes
const routesResponse = await fetch('/api/routes');
const routes = await routesResponse.json();
const routeSelect = document.getElementById('routeId');
routes.sort((a, b) => {
const aNum = parseInt(a.shortName) || 9999;
const bNum = parseInt(b.shortName) || 9999;
return aNum - bNum;
});
routeSelect.innerHTML = '<option value="">Select a route...</option>' +
routes.map(route =>
`<option value="${route.id}">${route.shortName} - ${route.longName}</option>`
).join('');
} catch (error) {
console.error('Failed to load stops and routes:', error);
}
}
// Start application
async function start() {
// Load stops and routes first
await loadStopsAndRoutes();
// Check URL parameters
const params = getUrlParams();
if (params.stopId && params.routeId) {
// Load from URL
currentStopId = params.stopId;
currentRouteId = params.routeId;
document.getElementById('stopId').value = params.stopId;
document.getElementById('routeId').value = params.routeId;
await loadTracker();
} else {
// Load defaults
const response = await fetch('/api/config');
const defaultConfig = await response.json();
document.getElementById('stopId').value = defaultConfig.defaults.stopId;
document.getElementById('routeId').value = defaultConfig.defaults.routeId;
// Auto-load defaults
await loadTracker();
}
}
start();
</script>
</body>
</html>