Basic data ingestion and analytics
This commit is contained in:
500
public/analytics.html
Normal file
500
public/analytics.html
Normal file
@@ -0,0 +1,500 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user