624 lines
20 KiB
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: '© <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>
|