feat: Initial commit - Train tracking system
Some checks failed
Auto Tag on Merge to Main / auto-tag (push) Successful in 27s
CI - Lint and Build / lint-backend (push) Failing after 30s
CI - Lint and Build / lint-frontend (push) Failing after 2s
CI - Lint and Build / build-frontend (push) Has been skipped
CI - Lint and Build / docker-build-test (push) Has been skipped

Complete real-time train tracking system for Spanish railways (Renfe/Cercanías):

- Backend API (Node.js/Express) with GTFS-RT polling workers
- Frontend dashboard (React/Vite) with Leaflet maps
- Real-time updates via Socket.io WebSocket
- PostgreSQL/PostGIS database with Flyway migrations
- Redis caching layer
- Docker Compose configuration for development and production
- Gitea CI/CD workflows (lint, auto-tag, release)
- Production deployment with nginx + Let's Encrypt SSL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Millaguie
2025-11-28 00:21:15 +01:00
commit 34c0cb50c7
64 changed files with 15577 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
/**
* Calculate bearing (direction) between two geographic points
* @param {number} lat1 - Starting latitude in degrees
* @param {number} lon1 - Starting longitude in degrees
* @param {number} lat2 - Ending latitude in degrees
* @param {number} lon2 - Ending longitude in degrees
* @returns {number} Bearing in degrees (0-360, where 0=North, 90=East, 180=South, 270=West)
*/
export function calculateBearing(lat1, lon1, lat2, lon2) {
// Convert to radians
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
let θ = Math.atan2(y, x);
// Convert to degrees and normalize to 0-360
let bearing = (θ * 180 / Math.PI + 360) % 360;
return bearing;
}
/**
* Calculate distance between two points in meters (Haversine formula)
* @param {number} lat1 - Starting latitude
* @param {number} lon1 - Starting longitude
* @param {number} lat2 - Ending latitude
* @param {number} lon2 - Ending longitude
* @returns {number} Distance in meters
*/
export function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // Earth radius in meters
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Minimum distance (in meters) to consider for bearing calculation
* Too small distances can result in inaccurate bearings
*/
export const MIN_DISTANCE_FOR_BEARING = 10;

View File

@@ -0,0 +1,126 @@
/**
* Infer train type based on train ID ranges
* Based on official Renfe train numbering system
*/
const TRAIN_TYPE_RANGES = [
{ min: 0, max: 1999, type: 'LONG_DISTANCE', name: 'Larga Distancia' },
{ min: 2000, max: 8999, type: 'MEDIUM_DISTANCE', name: 'Media Distancia' },
{ min: 9000, max: 9099, type: 'HIGH_SPEED', name: 'Media Distancia AV / AVE LD' },
{ min: 9100, max: 9299, type: 'HIGH_SPEED', name: 'Talgo 200' },
{ min: 9300, max: 9499, type: 'HIGH_SPEED', name: 'AVE Programacion Especial' },
{ min: 9500, max: 9899, type: 'HIGH_SPEED', name: 'AV MD / AVE LD' },
{ min: 9900, max: 9999, type: 'SERVICE', name: 'Servicio Interno AV' },
{ min: 10000, max: 10399, type: 'LONG_DISTANCE', name: 'Ramas Larga Distancia' },
{ min: 14000, max: 15999, type: 'LONG_DISTANCE', name: 'Prog. Especial LD' },
{ min: 16000, max: 26999, type: 'COMMUTER', name: 'Cercanias' },
{ min: 27000, max: 29999, type: 'COMMUTER', name: 'Prog. Especial Cercanias' },
{ min: 30000, max: 30999, type: 'LONG_DISTANCE', name: 'Especiales LD' },
{ min: 31000, max: 31999, type: 'MEDIUM_DISTANCE', name: 'Especiales MD' },
{ min: 32000, max: 33999, type: 'MEDIUM_DISTANCE', name: 'Prog. Especial MD' },
{ min: 34000, max: 35999, type: 'COMMUTER', name: 'Especiales Cercanias' },
{ min: 36000, max: 36999, type: 'SPECIAL', name: 'Prog. Especial Varios Op.' },
{ min: 37000, max: 39999, type: 'SPECIAL', name: 'Especiales Varios Op.' },
{ min: 40000, max: 49999, type: 'FREIGHT', name: 'Internacional Mercancias' },
{ min: 50000, max: 50999, type: 'FREIGHT', name: 'TECOS Red Terrestre' },
{ min: 51000, max: 51999, type: 'FREIGHT', name: 'TECOS Red Maritima' },
{ min: 52000, max: 52999, type: 'FREIGHT', name: 'TECOS Internacional' },
{ min: 53000, max: 53999, type: 'FREIGHT', name: 'Petroquimicos' },
{ min: 54000, max: 54999, type: 'FREIGHT', name: 'Material Agricola' },
{ min: 55000, max: 55999, type: 'FREIGHT', name: 'Minero/Construccion' },
{ min: 56000, max: 56999, type: 'FREIGHT', name: 'Prod. Manufacturados' },
{ min: 57000, max: 57999, type: 'FREIGHT', name: 'Portacoches' },
{ min: 58000, max: 58999, type: 'FREIGHT', name: 'Siderurgicos' },
{ min: 59000, max: 59999, type: 'FREIGHT', name: 'Auto/Sider. Internacional' },
{ min: 60000, max: 60999, type: 'FREIGHT', name: 'Surcos Polivalentes' },
{ min: 61000, max: 61999, type: 'FREIGHT', name: 'Servicio Interno Merc.' },
{ min: 62000, max: 79999, type: 'FREIGHT', name: 'Mercancias Varios Op.' },
{ min: 80000, max: 80999, type: 'FREIGHT', name: 'TECO P.E.' },
{ min: 81000, max: 81999, type: 'FREIGHT', name: 'Manufacturados/Agricola' },
{ min: 82000, max: 82999, type: 'FREIGHT', name: 'Prod. Industriales' },
{ min: 83000, max: 84999, type: 'FREIGHT', name: 'Polivalentes' },
{ min: 85000, max: 85999, type: 'FREIGHT', name: 'Estacional/Militar' },
{ min: 86000, max: 86999, type: 'FREIGHT', name: 'Servicio Interno Merc.' },
{ min: 87000, max: 89999, type: 'FREIGHT', name: 'Prog. Especial Varios Op.' },
{ min: 90000, max: 90999, type: 'FREIGHT', name: 'Transportes Excepcionales' },
{ min: 91000, max: 92999, type: 'FREIGHT', name: 'Excepc. Intermodales' },
{ min: 93000, max: 94999, type: 'FREIGHT', name: 'Excepc. Prod. Industriales' },
{ min: 95000, max: 96999, type: 'FREIGHT', name: 'Excepc. Polivalente' },
{ min: 97000, max: 99999, type: 'FREIGHT', name: 'Excepc. Varios Op.' },
];
/**
* Get train type info from train ID
* @param {string|number} trainId - The train ID
* @returns {{ type: string, name: string } | null}
*/
export function getTrainTypeFromId(trainId) {
// Extract numeric part from train_id (handle formats like "15000", "AVE-15000", etc.)
const numericMatch = String(trainId).match(/\d+/);
if (!numericMatch) return null;
const numericId = parseInt(numericMatch[0], 10);
for (const range of TRAIN_TYPE_RANGES) {
if (numericId >= range.min && numericId <= range.max) {
return {
type: range.type,
name: range.name,
};
}
}
return null;
}
/**
* Format train type for display
* @param {string} type - Train type code
* @param {string} inferredName - Inferred name from ID
* @returns {string}
*/
export function formatTrainType(type, inferredName = null) {
const typeMap = {
'HIGH_SPEED': 'Alta Velocidad (AVE)',
'LONG_DISTANCE': 'Larga Distancia',
'MEDIUM_DISTANCE': 'Media Distancia',
'REGIONAL': 'Regional',
'COMMUTER': 'Cercanias',
'FREIGHT': 'Mercancias',
'SERVICE': 'Servicio Interno',
'SPECIAL': 'Especial',
'RAIL': 'Ferrocarril',
'UNKNOWN': 'No especificado',
};
// If we have an inferred name, use it for more detail
if (inferredName) {
const baseType = typeMap[type] || type;
if (inferredName !== baseType) {
return `${baseType} - ${inferredName}`;
}
return baseType;
}
return typeMap[type] || type || 'No especificado';
}
/**
* Get color for train type (for map markers)
* @param {string} type - Train type code
* @returns {string} - Hex color
*/
export function getTrainTypeColor(type) {
const colorMap = {
'HIGH_SPEED': '#E74C3C', // Red - AVE
'LONG_DISTANCE': '#3498DB', // Blue - Long distance
'MEDIUM_DISTANCE': '#9B59B6', // Purple - Medium distance
'COMMUTER': '#2ECC71', // Green - Cercanias
'FREIGHT': '#95A5A6', // Gray - Freight
'SERVICE': '#F39C12', // Orange - Service
'SPECIAL': '#E67E22', // Dark orange - Special
'REGIONAL': '#1ABC9C', // Teal - Regional
};
return colorMap[type] || '#1a1a2e'; // Default dark blue
}