501 lines
17 KiB
HTML
501 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Bus Analytics - Skopje</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
|
<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;
|
|
}
|
|
|
|
.nav {
|
|
background: white;
|
|
padding: 10px 20px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.nav a {
|
|
color: #667eea;
|
|
text-decoration: none;
|
|
margin-right: 20px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.nav a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.controls {
|
|
background: white;
|
|
padding: 15px 20px;
|
|
margin: 20px;
|
|
border-radius: 8px;
|
|
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 select {
|
|
padding: 8px 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
min-width: 250px;
|
|
}
|
|
|
|
.btn {
|
|
padding: 8px 20px;
|
|
background: #667eea;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.btn:hover {
|
|
background: #5568d3;
|
|
}
|
|
|
|
.container {
|
|
padding: 20px;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.stats-cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stats-card {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.stats-card h3 {
|
|
font-size: 14px;
|
|
color: #666;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.stats-card .value {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: #667eea;
|
|
}
|
|
|
|
.stats-card .unit {
|
|
font-size: 14px;
|
|
color: #999;
|
|
}
|
|
|
|
.charts {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.chart-container {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.chart-container h2 {
|
|
font-size: 18px;
|
|
color: #333;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #999;
|
|
}
|
|
|
|
.db-stats {
|
|
background: #f8f9fa;
|
|
border-left: 4px solid #667eea;
|
|
padding: 15px;
|
|
margin: 20px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.db-stats h3 {
|
|
color: #333;
|
|
margin-bottom: 10px;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.db-stats p {
|
|
color: #666;
|
|
font-size: 14px;
|
|
margin: 5px 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Bus Analytics Dashboard</h1>
|
|
<div class="subtitle">Historical performance and delay analysis</div>
|
|
</div>
|
|
|
|
<div class="nav">
|
|
<a href="/">← Back to Live Tracker</a>
|
|
<a href="#" onclick="loadData(); return false;">Refresh Data</a>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<div class="control-group">
|
|
<label for="routeId">Bus Route</label>
|
|
<select id="routeId">
|
|
<option value="">Loading routes...</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn" onclick="loadAnalytics()">Load Analytics</button>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<div id="dbStats" class="db-stats" style="display: none;">
|
|
<h3>Database Status</h3>
|
|
<p id="dbInfo">Loading...</p>
|
|
</div>
|
|
|
|
<div id="statsCards" class="stats-cards">
|
|
<!-- Stats will be inserted here -->
|
|
</div>
|
|
|
|
<div class="charts">
|
|
<div class="chart-container">
|
|
<h2>Delay Distribution</h2>
|
|
<canvas id="delayChart"></canvas>
|
|
</div>
|
|
|
|
<div class="chart-container">
|
|
<h2>Hourly Service Pattern (Last 7 Days)</h2>
|
|
<canvas id="hourlyChart"></canvas>
|
|
</div>
|
|
|
|
<div class="chart-container">
|
|
<h2>On-Time Performance</h2>
|
|
<canvas id="performanceChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let currentRouteId = null;
|
|
let delayChart = null;
|
|
let hourlyChart = null;
|
|
let performanceChart = null;
|
|
|
|
// Load stops and routes
|
|
async function loadRoutes() {
|
|
try {
|
|
const response = await fetch('/api/routes');
|
|
const routes = await response.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 routes:', error);
|
|
}
|
|
}
|
|
|
|
// Load database statistics
|
|
async function loadDbStats() {
|
|
try {
|
|
const response = await fetch('/api/stats/db');
|
|
const stats = await response.json();
|
|
|
|
document.getElementById('dbStats').style.display = 'block';
|
|
document.getElementById('dbInfo').innerHTML = `
|
|
<strong>Records:</strong> ${stats.vehiclePositions.toLocaleString()} vehicle positions,
|
|
${stats.arrivalRecords.toLocaleString()} arrival records<br>
|
|
<strong>Time Range:</strong> ${stats.oldestRecord ? new Date(stats.oldestRecord).toLocaleString() : 'N/A'}
|
|
to ${stats.newestRecord ? new Date(stats.newestRecord).toLocaleString() : 'N/A'}
|
|
`;
|
|
} catch (error) {
|
|
console.error('Failed to load DB stats:', error);
|
|
}
|
|
}
|
|
|
|
// Load analytics for selected route
|
|
async function loadAnalytics() {
|
|
const routeId = document.getElementById('routeId').value;
|
|
|
|
if (!routeId) {
|
|
alert('Please select a route');
|
|
return;
|
|
}
|
|
|
|
currentRouteId = routeId;
|
|
|
|
await Promise.all([
|
|
loadDelayStats(),
|
|
loadHourlyPattern(),
|
|
loadDbStats()
|
|
]);
|
|
}
|
|
|
|
// Load delay statistics
|
|
async function loadDelayStats() {
|
|
try {
|
|
const response = await fetch(`/api/stats/route/${currentRouteId}/delays?hours=24`);
|
|
const stats = await response.json();
|
|
|
|
// Check if we have data
|
|
if (!stats.total_arrivals || stats.total_arrivals === 0) {
|
|
document.getElementById('statsCards').innerHTML = `
|
|
<div class="stats-card" style="grid-column: 1 / -1; text-align: center; padding: 40px;">
|
|
<h3 style="color: #999; font-size: 18px; margin-bottom: 10px;">No Arrival Data Yet</h3>
|
|
<p style="color: #666; font-size: 14px;">Start tracking a stop to collect arrival data.<br>Go to the <a href="/" style="color: #667eea;">Live Tracker</a> and select a stop and route.</p>
|
|
</div>
|
|
`;
|
|
|
|
// Clear charts
|
|
if (performanceChart) performanceChart.destroy();
|
|
if (delayChart) delayChart.destroy();
|
|
if (hourlyChart) hourlyChart.destroy();
|
|
return;
|
|
}
|
|
|
|
// Update stats cards
|
|
const cardsHTML = `
|
|
<div class="stats-card">
|
|
<h3>Total Arrivals (24h)</h3>
|
|
<div class="value">${stats.total_arrivals || 0}</div>
|
|
</div>
|
|
<div class="stats-card">
|
|
<h3>Average Delay</h3>
|
|
<div class="value">${stats.avg_delay ? Math.round(stats.avg_delay / 60) : 0}</div>
|
|
<div class="unit">minutes</div>
|
|
</div>
|
|
<div class="stats-card">
|
|
<h3>On-Time Rate</h3>
|
|
<div class="value">${stats.total_arrivals > 0 ? Math.round((stats.on_time_count / stats.total_arrivals) * 100) : 0}%</div>
|
|
</div>
|
|
<div class="stats-card">
|
|
<h3>Maximum Delay</h3>
|
|
<div class="value">${stats.max_delay ? Math.round(stats.max_delay / 60) : 0}</div>
|
|
<div class="unit">minutes</div>
|
|
</div>
|
|
`;
|
|
document.getElementById('statsCards').innerHTML = cardsHTML;
|
|
|
|
// Update performance chart
|
|
if (performanceChart) {
|
|
performanceChart.destroy();
|
|
}
|
|
|
|
const ctx = document.getElementById('performanceChart');
|
|
performanceChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['On Time (±2 min)', 'Late (>2 min)', 'Early (<-2 min)'],
|
|
datasets: [{
|
|
data: [
|
|
stats.on_time_count || 0,
|
|
stats.late_count || 0,
|
|
stats.early_count || 0
|
|
],
|
|
backgroundColor: [
|
|
'#4caf50',
|
|
'#ff6b6b',
|
|
'#2196f3'
|
|
]
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update delay distribution chart
|
|
if (delayChart) {
|
|
delayChart.destroy();
|
|
}
|
|
|
|
const delayCtx = document.getElementById('delayChart');
|
|
delayChart = new Chart(delayCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: ['Early', 'On Time', 'Late'],
|
|
datasets: [{
|
|
label: 'Number of Arrivals',
|
|
data: [
|
|
stats.early_count || 0,
|
|
stats.on_time_count || 0,
|
|
stats.late_count || 0
|
|
],
|
|
backgroundColor: [
|
|
'#2196f3',
|
|
'#4caf50',
|
|
'#ff6b6b'
|
|
]
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load delay stats:', error);
|
|
}
|
|
}
|
|
|
|
// Load hourly pattern
|
|
async function loadHourlyPattern() {
|
|
try {
|
|
const response = await fetch(`/api/stats/route/${currentRouteId}/hourly?days=7`);
|
|
const pattern = await response.json();
|
|
|
|
if (hourlyChart) {
|
|
hourlyChart.destroy();
|
|
}
|
|
|
|
const ctx = document.getElementById('hourlyChart');
|
|
hourlyChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: pattern.map(p => `${p.hour}:00`),
|
|
datasets: [
|
|
{
|
|
label: 'Average Delay (seconds)',
|
|
data: pattern.map(p => p.avg_delay || 0),
|
|
borderColor: '#ff6b6b',
|
|
backgroundColor: 'rgba(255, 107, 107, 0.1)',
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Number of Arrivals',
|
|
data: pattern.map(p => p.arrival_count || 0),
|
|
borderColor: '#667eea',
|
|
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
|
yAxisID: 'y1',
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
interaction: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
},
|
|
scales: {
|
|
y: {
|
|
type: 'linear',
|
|
display: true,
|
|
position: 'left',
|
|
title: {
|
|
display: true,
|
|
text: 'Delay (seconds)'
|
|
}
|
|
},
|
|
y1: {
|
|
type: 'linear',
|
|
display: true,
|
|
position: 'right',
|
|
title: {
|
|
display: true,
|
|
text: 'Arrivals'
|
|
},
|
|
grid: {
|
|
drawOnChartArea: false,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load hourly pattern:', error);
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
async function init() {
|
|
await loadRoutes();
|
|
await loadDbStats();
|
|
|
|
// Load default route analytics if available
|
|
const response = await fetch('/api/config');
|
|
const config = await response.json();
|
|
if (config.defaults.routeId) {
|
|
document.getElementById('routeId').value = config.defaults.routeId;
|
|
await loadAnalytics();
|
|
}
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|