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

69
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,69 @@
# Multi-stage Dockerfile para Frontend React + Vite
FROM node:20-alpine AS build
# Argumentos de construcción
ARG VITE_API_URL
ARG VITE_WS_URL
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_WS_URL=${VITE_WS_URL}
WORKDIR /app
# Copiar archivos de dependencias
COPY package*.json ./
# Instalar dependencias
RUN npm install
# Copiar código fuente
COPY . .
# Build de producción
RUN npm run build
# ================================
# Stage de producción con Nginx
# ================================
FROM nginx:alpine AS production
# Copiar archivos compilados
COPY --from=build /app/dist /usr/share/nginx/html
# Copiar configuración de nginx personalizada
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Crear usuario no-root y configurar permisos
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown nginx:nginx /etc/nginx/conf.d/default.conf && \
chmod 644 /etc/nginx/conf.d/default.conf && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
USER nginx
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
# ================================
# Stage de desarrollo
# ================================
FROM node:20-alpine AS development
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trenes en Tiempo Real - España</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

44
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,44 @@
# Configuración de nginx para el contenedor frontend
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Logs
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1000;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/x-javascript application/xhtml+xml;
# SPA fallback - todas las rutas devuelven index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache para assets estáticos con hash
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# No cachear index.html
location = /index.html {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Denegar acceso a archivos ocultos
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "trenes-frontend",
"version": "1.0.0",
"description": "Frontend para sistema de tracking de trenes en tiempo real",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"socket.io-client": "^4.6.1",
"date-fns": "^3.0.6",
"lucide-react": "^0.309.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5"
}
}

188
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { TrainMap } from './components/TrainMap';
import { TrainInfo } from './components/TrainInfo';
import { Timeline } from './components/Timeline';
import { Dashboard } from './components/Dashboard';
import { useTrains } from './hooks/useTrains';
import { useTimeline } from './hooks/useTimeline';
import { Train, Activity, History, BarChart3, Map } from 'lucide-react';
function App() {
const [activeView, setActiveView] = useState('map'); // 'map' or 'dashboard'
const { trains, selectedTrain, selectTrain, isConnected, error, stats } = useTrains();
const {
isTimelineMode,
isPlaying,
isLoading: isTimelineLoading,
currentTime,
timeRange,
playbackSpeed,
timelinePositions,
toggleTimelineMode,
togglePlay,
skip,
seekTo,
changeSpeed,
} = useTimeline();
// Use timeline positions when in timeline mode, otherwise live trains
const displayTrains = isTimelineMode ? timelinePositions : trains;
const formatLastUpdate = (timestamp) => {
if (!timestamp) return 'Nunca';
const date = new Date(timestamp);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return `Hace ${diff} segundos`;
if (diff < 3600) return `Hace ${Math.floor(diff / 60)} minutos`;
return date.toLocaleTimeString('es-ES');
};
if (error && activeView === 'map') {
return (
<div className="app">
<div className="error">
<h2>Error de Conexion</h2>
<p>{error}</p>
<p style={{ fontSize: '0.9rem', marginTop: '10px' }}>
Verifica que el servidor este ejecutandose
</p>
</div>
</div>
);
}
return (
<div className="app">
<header className="header">
<div className="header-title">
{activeView === 'dashboard' ? (
<BarChart3 size={32} />
) : isTimelineMode ? (
<History size={32} />
) : (
<Train size={32} />
)}
<span>
{activeView === 'dashboard'
? 'Dashboard de Trenes'
: isTimelineMode
? 'Reproduccion Historica - Espana'
: 'Trenes en Tiempo Real - Espana'}
</span>
</div>
<div className="nav-tabs">
<button
className={`nav-tab ${activeView === 'map' ? 'active' : ''}`}
onClick={() => setActiveView('map')}
>
<Map size={18} />
Mapa
</button>
<button
className={`nav-tab ${activeView === 'dashboard' ? 'active' : ''}`}
onClick={() => setActiveView('dashboard')}
>
<BarChart3 size={18} />
Dashboard
</button>
</div>
{activeView === 'map' && (
<div className="header-stats">
<div className="stat">
<span className="stat-label">Modo</span>
<span className="stat-value">
<span className={`status-indicator ${isTimelineMode ? 'timeline' : (isConnected ? 'active' : 'inactive')}`} />
{isTimelineMode ? 'Historico' : (isConnected ? 'Tiempo Real' : 'Desconectado')}
</span>
</div>
<div className="stat">
<span className="stat-label">Trenes</span>
<span className="stat-value">{displayTrains.length}</span>
</div>
<div className="stat">
<span className="stat-label">{isTimelineMode ? 'Tiempo Actual' : 'Ultima Actualizacion'}</span>
<span className="stat-value">
{isTimelineMode
? new Date(currentTime).toLocaleTimeString('es-ES')
: formatLastUpdate(stats.last_update)}
</span>
</div>
</div>
)}
</header>
{activeView === 'dashboard' ? (
<Dashboard />
) : (
<main className="main-content">
<TrainMap
trains={displayTrains}
selectedTrain={selectedTrain}
onTrainClick={selectTrain}
/>
<aside className="sidebar">
<div className="sidebar-header">
<h2>
{selectedTrain ? `Tren ${selectedTrain.train_id}` : 'Informacion'}
</h2>
<p className="sidebar-subtitle">
{selectedTrain
? 'Detalles del tren seleccionado'
: `${displayTrains.length} trenes en el mapa`}
</p>
</div>
<div className="sidebar-content">
{!isConnected && !displayTrains.length && !isTimelineMode ? (
<div className="loading">
<Activity size={48} style={{ animation: 'pulse 2s ease-in-out infinite' }} />
<p style={{ marginTop: '10px' }}>Conectando...</p>
</div>
) : isTimelineLoading ? (
<div className="loading">
<Activity size={48} style={{ animation: 'pulse 2s ease-in-out infinite' }} />
<p style={{ marginTop: '10px' }}>Cargando historico...</p>
</div>
) : (
<TrainInfo train={selectedTrain} onClose={() => selectTrain(null)} />
)}
</div>
</aside>
<Timeline
isTimelineMode={isTimelineMode}
isPlaying={isPlaying}
isLoading={isTimelineLoading}
currentTime={currentTime}
timeRange={timeRange}
playbackSpeed={playbackSpeed}
onToggleMode={toggleTimelineMode}
onTogglePlay={togglePlay}
onSkip={skip}
onSeek={seekTo}
onChangeSpeed={changeSpeed}
/>
</main>
)}
</div>
);
}
export default App;
// Add pulse animation
const style = document.createElement('style');
style.textContent = `
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`;
document.head.appendChild(style);

View File

@@ -0,0 +1,447 @@
import React, { useMemo } from 'react';
import {
BarChart3,
Clock,
Train,
AlertTriangle,
CheckCircle,
TrendingUp,
Play,
Pause,
SkipBack,
SkipForward,
Radio,
Calendar,
} from 'lucide-react';
import { useDashboard } from '../hooks/useDashboard';
// Mini chart component for timeline
function MiniChart({ data, dataKey, color, height = 60 }) {
if (!data || data.length === 0) return null;
const values = data.map(d => d[dataKey] || 0);
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * 100;
const y = 100 - ((d[dataKey] - min) / range) * 100;
return `${x},${y}`;
}).join(' ');
return (
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height }}>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
// Stat card component
function StatCard({ icon: Icon, label, value, subValue, color = '#3498DB', trend }) {
return (
<div className="stat-card">
<div className="stat-card-icon" style={{ backgroundColor: `${color}20`, color }}>
<Icon size={24} />
</div>
<div className="stat-card-content">
<span className="stat-card-label">{label}</span>
<span className="stat-card-value">{value}</span>
{subValue && <span className="stat-card-subvalue">{subValue}</span>}
{trend !== undefined && (
<span className={`stat-card-trend ${trend >= 0 ? 'positive' : 'negative'}`}>
{trend >= 0 ? '+' : ''}{trend}%
</span>
)}
</div>
</div>
);
}
// Progress bar component
function ProgressBar({ value, max, color, label }) {
const percentage = max > 0 ? (value / max) * 100 : 0;
return (
<div className="progress-bar-container">
<div className="progress-bar-header">
<span className="progress-bar-label">{label}</span>
<span className="progress-bar-value">{value}</span>
</div>
<div className="progress-bar-track">
<div
className="progress-bar-fill"
style={{ width: `${percentage}%`, backgroundColor: color }}
/>
</div>
</div>
);
}
// Punctuality donut chart
function PunctualityDonut({ data }) {
if (!data) return null;
const total = Object.values(data).reduce((a, b) => a + b, 0);
if (total === 0) return null;
const segments = [
{ key: 'on_time', color: '#27AE60', label: 'Puntual' },
{ key: 'minor_delay', color: '#F39C12', label: '1-5 min' },
{ key: 'moderate_delay', color: '#E67E22', label: '6-15 min' },
{ key: 'severe_delay', color: '#E74C3C', label: '>15 min' },
{ key: 'early', color: '#3498DB', label: 'Adelantado' },
];
let currentAngle = 0;
const paths = segments.map(({ key, color }) => {
const value = data[key] || 0;
const percentage = value / total;
const angle = percentage * 360;
if (value === 0) return null;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
currentAngle = endAngle;
const startRad = (startAngle - 90) * (Math.PI / 180);
const endRad = (endAngle - 90) * (Math.PI / 180);
const x1 = 50 + 40 * Math.cos(startRad);
const y1 = 50 + 40 * Math.sin(startRad);
const x2 = 50 + 40 * Math.cos(endRad);
const y2 = 50 + 40 * Math.sin(endRad);
const largeArc = angle > 180 ? 1 : 0;
return (
<path
key={key}
d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${largeArc} 1 ${x2} ${y2} Z`}
fill={color}
/>
);
});
return (
<div className="donut-chart">
<svg viewBox="0 0 100 100">
{paths}
<circle cx="50" cy="50" r="25" fill="var(--bg-secondary)" />
</svg>
<div className="donut-legend">
{segments.map(({ key, color, label }) => {
const value = data[key] || 0;
if (value === 0) return null;
return (
<div key={key} className="donut-legend-item">
<span className="donut-legend-color" style={{ backgroundColor: color }} />
<span className="donut-legend-label">{label}</span>
<span className="donut-legend-value">{value}</span>
</div>
);
})}
</div>
</div>
);
}
// Lines ranking table
function LinesTable({ lines }) {
if (!lines || lines.length === 0) {
return <div className="empty-state">Sin datos de lineas</div>;
}
return (
<div className="lines-table">
<div className="lines-table-header">
<span>Linea</span>
<span>Trenes</span>
<span>Retraso Med.</span>
<span>Puntualidad</span>
</div>
{lines.slice(0, 10).map((line, index) => (
<div key={`${line.nucleo}:${line.line_code}-${index}`} className="lines-table-row">
<span className="line-code">
{line.line_code}
{line.nucleo_name && <span className="line-nucleo"> ({line.nucleo_name})</span>}
</span>
<span>{line.unique_trains}</span>
<span style={{ color: line.avg_delay > 5 ? '#E74C3C' : '#27AE60' }}>
{parseFloat(line.avg_delay).toFixed(1)} min
</span>
<span style={{ color: line.punctuality_pct >= 80 ? '#27AE60' : '#E74C3C' }}>
{line.punctuality_pct}%
</span>
</div>
))}
</div>
);
}
// Time control bar
function TimeControl({ currentTime, isLive, availableRange, onSeek, onGoLive, onSkip }) {
const formatTime = (date) => {
return date.toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleSliderChange = (e) => {
if (!availableRange.earliest || !availableRange.latest) return;
const percentage = parseFloat(e.target.value);
const range = availableRange.latest.getTime() - availableRange.earliest.getTime();
const newTime = new Date(availableRange.earliest.getTime() + range * percentage);
onSeek(newTime);
};
const getSliderValue = () => {
if (!availableRange.earliest || !availableRange.latest) return 1;
const range = availableRange.latest.getTime() - availableRange.earliest.getTime();
if (range === 0) return 1;
return (currentTime.getTime() - availableRange.earliest.getTime()) / range;
};
return (
<div className="time-control">
<div className="time-control-header">
<div className="time-display">
<Clock size={20} />
<span className="current-time">{formatTime(currentTime)}</span>
{isLive && (
<span className="live-badge">
<Radio size={14} />
EN VIVO
</span>
)}
</div>
<div className="time-buttons">
<button onClick={() => onSkip(-60)} title="Retroceder 1 hora">
<SkipBack size={16} />
-1h
</button>
<button onClick={() => onSkip(-10)} title="Retroceder 10 min">
-10m
</button>
<button onClick={() => onSkip(10)} title="Avanzar 10 min">
+10m
</button>
<button onClick={() => onSkip(60)} title="Avanzar 1 hora">
+1h
<SkipForward size={16} />
</button>
<button
onClick={onGoLive}
className={`live-button ${isLive ? 'active' : ''}`}
title="Ver en tiempo real"
>
<Radio size={16} />
En Vivo
</button>
</div>
</div>
<div className="time-slider-container">
<input
type="range"
min="0"
max="1"
step="0.001"
value={getSliderValue()}
onChange={handleSliderChange}
className="time-slider"
/>
<div className="time-range-labels">
<span>{availableRange.earliest ? formatTime(availableRange.earliest) : '...'}</span>
<span>{availableRange.latest ? formatTime(availableRange.latest) : '...'}</span>
</div>
</div>
</div>
);
}
export function Dashboard() {
const {
isLive,
currentTime,
stats,
timeline,
linesRanking,
availableRange,
isLoading,
error,
seekTo,
goLive,
skip,
} = useDashboard();
// Calculate status totals
const statusTotal = useMemo(() => {
if (!stats?.status_breakdown) return 0;
return Object.values(stats.status_breakdown).reduce((a, b) => a + b, 0);
}, [stats]);
if (error) {
return (
<div className="dashboard-error">
<AlertTriangle size={48} />
<h3>Error al cargar el dashboard</h3>
<p>{error}</p>
</div>
);
}
return (
<div className="dashboard">
<TimeControl
currentTime={currentTime}
isLive={isLive}
availableRange={availableRange}
onSeek={seekTo}
onGoLive={goLive}
onSkip={skip}
/>
{isLoading && !stats ? (
<div className="dashboard-loading">
<div className="spinner" />
<p>Cargando datos...</p>
</div>
) : (
<>
{/* Main stats row */}
<div className="stats-row">
<StatCard
icon={Train}
label="Trenes Activos"
value={stats?.total_trains || 0}
color="#3498DB"
/>
<StatCard
icon={CheckCircle}
label="Puntualidad"
value={`${stats?.punctuality_percentage || 0}%`}
subValue="<= 5 min retraso"
color="#27AE60"
/>
<StatCard
icon={Clock}
label="Retraso Medio"
value={`${stats?.average_delay || 0} min`}
color={parseFloat(stats?.average_delay) > 5 ? '#E74C3C' : '#27AE60'}
/>
<StatCard
icon={AlertTriangle}
label="Retrasos Graves"
value={stats?.punctuality_breakdown?.severe_delay || 0}
subValue=">15 min retraso"
color="#E74C3C"
/>
</div>
<div className="dashboard-grid">
{/* Status breakdown */}
<div className="dashboard-card">
<h3>
<Train size={18} />
Estado de Trenes
</h3>
<div className="status-bars">
<ProgressBar
label="En transito"
value={stats?.status_breakdown?.IN_TRANSIT_TO || 0}
max={statusTotal}
color="#27AE60"
/>
<ProgressBar
label="Parado en estacion"
value={stats?.status_breakdown?.STOPPED_AT || 0}
max={statusTotal}
color="#E74C3C"
/>
<ProgressBar
label="Llegando a estacion"
value={stats?.status_breakdown?.INCOMING_AT || 0}
max={statusTotal}
color="#F39C12"
/>
</div>
</div>
{/* Punctuality breakdown */}
<div className="dashboard-card">
<h3>
<Clock size={18} />
Distribucion de Puntualidad
</h3>
<PunctualityDonut data={stats?.punctuality_breakdown} />
</div>
{/* Timeline chart */}
<div className="dashboard-card wide">
<h3>
<TrendingUp size={18} />
Evolucion Temporal (Ultima hora)
</h3>
<div className="timeline-charts">
<div className="timeline-chart">
<span className="chart-label">Trenes</span>
<MiniChart data={timeline} dataKey="train_count" color="#3498DB" height={50} />
</div>
<div className="timeline-chart">
<span className="chart-label">Puntualidad %</span>
<MiniChart data={timeline} dataKey="punctuality_pct" color="#27AE60" height={50} />
</div>
<div className="timeline-chart">
<span className="chart-label">Retraso Medio</span>
<MiniChart data={timeline} dataKey="avg_delay" color="#E74C3C" height={50} />
</div>
</div>
</div>
{/* Lines by activity */}
<div className="dashboard-card">
<h3>
<BarChart3 size={18} />
Trenes por Linea
</h3>
<div className="lines-bars">
{stats?.lines_breakdown &&
Array.isArray(stats.lines_breakdown) &&
stats.lines_breakdown
.slice(0, 8)
.map((line) => (
<ProgressBar
key={`${line.nucleo}:${line.line_code}`}
label={`${line.line_code} (${line.nucleo_name})`}
value={line.count}
max={stats.lines_breakdown[0]?.count || 1}
color="#3498DB"
/>
))}
</div>
</div>
{/* Lines ranking */}
<div className="dashboard-card">
<h3>
<AlertTriangle size={18} />
Ranking de Lineas (Peor Puntualidad)
</h3>
<LinesTable lines={linesRanking} />
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
import React from 'react';
import { Play, Pause, SkipBack, SkipForward, Clock, History, X, Loader } from 'lucide-react';
export function Timeline({
isTimelineMode,
isPlaying,
isLoading,
currentTime,
timeRange,
playbackSpeed,
onToggleMode,
onTogglePlay,
onSkip,
onSeek,
onChangeSpeed,
}) {
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const formatDate = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
});
};
const progress = timeRange.end > timeRange.start
? ((currentTime - timeRange.start) / (timeRange.end - timeRange.start)) * 100
: 0;
const speedOptions = [1, 2, 5, 10];
return (
<div className="timeline-container">
{!isTimelineMode ? (
// Modo tiempo real - botón para activar timeline
<div className="timeline-realtime">
<div className="timeline-realtime-info">
<Clock size={18} />
<span>Modo Tiempo Real</span>
</div>
<button
className="timeline-btn timeline-btn-primary"
onClick={onToggleMode}
title="Activar modo histórico"
>
<History size={16} />
Ver Histórico
</button>
</div>
) : (
// Modo timeline
<>
<div className="timeline-header">
<div className="timeline-title-section">
<History size={18} />
<span className="timeline-title">Reproducción Histórica</span>
{isLoading && (
<Loader size={16} className="timeline-loading" />
)}
</div>
<button
className="timeline-btn timeline-btn-close"
onClick={onToggleMode}
title="Volver a tiempo real"
>
<X size={16} />
</button>
</div>
<div className="timeline-controls">
<button
className="timeline-btn"
onClick={() => onSkip(-300)}
title="Retroceder 5 minutos"
disabled={isLoading}
>
<SkipBack size={16} />
</button>
<button
className="timeline-btn"
onClick={() => onSkip(-60)}
title="Retroceder 1 minuto"
disabled={isLoading}
>
-1m
</button>
<button
className="timeline-btn timeline-btn-play"
onClick={onTogglePlay}
title={isPlaying ? 'Pausar' : 'Reproducir'}
disabled={isLoading}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button
className="timeline-btn"
onClick={() => onSkip(60)}
title="Avanzar 1 minuto"
disabled={isLoading}
>
+1m
</button>
<button
className="timeline-btn"
onClick={() => onSkip(300)}
title="Avanzar 5 minutos"
disabled={isLoading}
>
<SkipForward size={16} />
</button>
<div className="timeline-speed">
<span>Velocidad:</span>
<select
value={playbackSpeed}
onChange={(e) => onChangeSpeed(Number(e.target.value))}
disabled={isLoading}
>
{speedOptions.map(speed => (
<option key={speed} value={speed}>{speed}x</option>
))}
</select>
</div>
</div>
<div className="timeline-slider-container">
<input
type="range"
className="timeline-slider"
min={timeRange.start}
max={timeRange.end}
value={currentTime}
onChange={(e) => onSeek(parseInt(e.target.value, 10))}
disabled={isLoading}
/>
<div
className="timeline-progress"
style={{ width: `${progress}%` }}
/>
</div>
<div className="timeline-time-display">
<span className="timeline-time-label">
{formatDate(timeRange.start)} {formatTime(timeRange.start)}
</span>
<span className="timeline-time-current">
{formatTime(currentTime)}
</span>
<span className="timeline-time-label">
{formatDate(timeRange.end)} {formatTime(timeRange.end)}
</span>
</div>
{isLoading && (
<div className="timeline-loading-message">
Cargando datos históricos...
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,310 @@
import React from 'react';
import { X } from 'lucide-react';
import { getTrainTypeFromId, formatTrainType as formatTrainTypeUtil } from '../utils/trainTypes';
export function TrainInfo({ train, onClose }) {
if (!train) {
return (
<div className="empty-state">
<p>Selecciona un tren en el mapa</p>
<p style={{ fontSize: '0.85rem', opacity: 0.7 }}>
Haz clic en cualquier marcador para ver información detallada
</p>
</div>
);
}
const formatTimestamp = (timestamp) => {
if (!timestamp) return 'N/A';
const date = new Date(timestamp);
return date.toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const formatSpeed = (speed) => {
if (speed == null) return 'N/A';
return `${Math.round(speed)} km/h`;
};
const formatBearing = (bearing) => {
if (bearing == null) return 'N/A';
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const index = Math.round(bearing / 45) % 8;
return `${Math.round(bearing)}° (${directions[index]})`;
};
const formatStatus = (status) => {
const statusMap = {
'INCOMING_AT': 'Llegando a estacion',
'STOPPED_AT': 'Parado en estacion',
'IN_TRANSIT_TO': 'En transito',
'UNKNOWN': 'Desconocido',
};
return statusMap[status] || status || 'N/A';
};
// Get train type - use API value if available, otherwise infer from ID
// Returns { text: string, isInferred: boolean }
const getDisplayTrainType = () => {
// If we have a valid train_type from API (not UNKNOWN), use it
if (train.train_type && train.train_type !== 'UNKNOWN') {
return { text: formatTrainTypeUtil(train.train_type), isInferred: false };
}
// Try to infer from train ID
const inferred = getTrainTypeFromId(train.train_id);
if (inferred) {
return { text: formatTrainTypeUtil(inferred.type, inferred.name), isInferred: true };
}
return { text: 'No especificado', isInferred: false };
};
const trainTypeInfo = getDisplayTrainType();
const formatOccupancy = (status) => {
const occupancyMap = {
'EMPTY': 'Vacío',
'MANY_SEATS_AVAILABLE': 'Muchos asientos libres',
'FEW_SEATS_AVAILABLE': 'Pocos asientos libres',
'STANDING_ROOM_ONLY': 'Solo de pie',
'CRUSHED_STANDING_ROOM_ONLY': 'Muy lleno',
'FULL': 'Completo',
'NOT_ACCEPTING_PASSENGERS': 'No admite pasajeros',
};
return occupancyMap[status] || status || 'N/A';
};
const formatDelay = (minutes) => {
if (minutes == null || minutes === 0) return 'Puntual';
if (minutes < 0) return `${Math.abs(minutes)} min adelantado`;
return `${minutes} min de retraso`;
};
const formatTime = (isoString) => {
if (!isoString) return null;
const date = new Date(isoString);
return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
};
// Check if we have fleet data
const hasFleetData = train.codLinea || train.retrasoMin !== undefined;
return (
<div className="train-info">
<div className="info-section">
<h3>Identificación</h3>
<div className="info-grid">
<div className="info-item">
<span className="info-label">ID Tren</span>
<span className="info-value">{train.train_id}</span>
</div>
<div className="info-item">
<span className="info-label">Tipo</span>
<span className="info-value">
{trainTypeInfo.text}
{trainTypeInfo.isInferred && (
<span style={{
marginLeft: '6px',
fontSize: '0.7rem',
color: '#888',
fontStyle: 'italic'
}}>
(inferido)
</span>
)}
</span>
</div>
{train.service_name && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Servicio</span>
<span className="info-value">{train.service_name}</span>
</div>
)}
{train.trip_id && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">ID Viaje</span>
<span className="info-value" style={{ fontSize: '0.85rem' }}>{train.trip_id}</span>
</div>
)}
{train.route_id && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Ruta</span>
<span className="info-value">{train.route_id}</span>
</div>
)}
{train.codLinea && (
<div className="info-item">
<span className="info-label">Linea</span>
<span className="info-value" style={{ fontWeight: 'bold', color: '#3498DB' }}>
{train.codLinea}
</span>
</div>
)}
{train.nucleo && (
<div className="info-item">
<span className="info-label">Nucleo</span>
<span className="info-value">{train.nucleo}</span>
</div>
)}
</div>
</div>
{hasFleetData && (
<div className="info-section">
<h3>Trayecto</h3>
<div className="info-grid">
{(train.codEstOrig || train.estacionOrigen) && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Origen</span>
<span className="info-value">
{train.estacionOrigen || train.codEstOrig}
{train.estacionOrigen && train.codEstOrig && (
<span style={{ marginLeft: '8px', color: '#888', fontSize: '0.8rem' }}>
({train.codEstOrig})
</span>
)}
</span>
</div>
)}
{(train.codEstDest || train.estacionDestino) && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Destino</span>
<span className="info-value">
{train.estacionDestino || train.codEstDest}
{train.estacionDestino && train.codEstDest && (
<span style={{ marginLeft: '8px', color: '#888', fontSize: '0.8rem' }}>
({train.codEstDest})
</span>
)}
</span>
</div>
)}
{(train.codEstAct || train.estacionActual) && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Estacion actual</span>
<span className="info-value">
{train.estacionActual || train.codEstAct}
{train.estacionActual && train.codEstAct && (
<span style={{ marginLeft: '8px', color: '#888', fontSize: '0.8rem' }}>
({train.codEstAct})
</span>
)}
</span>
</div>
)}
{(train.codEstSig || train.estacionSiguiente) && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Siguiente estacion</span>
<span className="info-value">
{train.estacionSiguiente || train.codEstSig}
{train.estacionSiguiente && train.codEstSig && (
<span style={{ marginLeft: '8px', color: '#888', fontSize: '0.8rem' }}>
({train.codEstSig})
</span>
)}
{train.horaLlegadaSigEst && (
<span style={{ marginLeft: '8px', color: '#666', fontSize: '0.9rem' }}>
- llegada: {formatTime(train.horaLlegadaSigEst)}
</span>
)}
</span>
</div>
)}
{train.via && (
<div className="info-item">
<span className="info-label">Via</span>
<span className="info-value">{train.via}</span>
</div>
)}
{train.accesible !== undefined && (
<div className="info-item">
<span className="info-label">Accesible</span>
<span className="info-value">{train.accesible ? 'Si' : 'No'}</span>
</div>
)}
</div>
</div>
)}
{train.retrasoMin !== undefined && (
<div className="info-section">
<h3>Puntualidad</h3>
<div className="info-grid">
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Estado</span>
<span className="info-value" style={{
color: train.retrasoMin > 0 ? '#E74C3C' : '#2ECC71',
fontWeight: 'bold'
}}>
{formatDelay(train.retrasoMin)}
</span>
</div>
</div>
</div>
)}
<div className="info-section">
<h3>Posición</h3>
<div className="info-grid">
<div className="info-item">
<span className="info-label">Latitud</span>
<span className="info-value">{train.latitude.toFixed(6)}</span>
</div>
<div className="info-item">
<span className="info-label">Longitud</span>
<span className="info-value">{train.longitude.toFixed(6)}</span>
</div>
</div>
</div>
<div className="info-section">
<h3>Estado</h3>
<div className="info-grid">
<div className="info-item">
<span className="info-label">Estado</span>
<span className="info-value">{formatStatus(train.status)}</span>
</div>
<div className="info-item">
<span className="info-label">Velocidad</span>
<span className="info-value">{formatSpeed(train.speed)}</span>
</div>
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Dirección</span>
<span className="info-value">{formatBearing(train.bearing)}</span>
</div>
{train.occupancy_status && (
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Ocupación</span>
<span className="info-value">{formatOccupancy(train.occupancy_status)}</span>
</div>
)}
</div>
</div>
<div className="info-section">
<h3>Tiempo</h3>
<div className="info-grid">
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Última actualización</span>
<span className="info-value" style={{ fontSize: '0.9rem' }}>
{formatTimestamp(train.timestamp)}
</span>
</div>
<div className="info-item" style={{ gridColumn: '1 / -1' }}>
<span className="info-label">Registrado</span>
<span className="info-value" style={{ fontSize: '0.9rem' }}>
{formatTimestamp(train.recorded_at)}
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,361 @@
import React, { useMemo, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, Tooltip } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useStations } from '../hooks/useStations';
import { getTrainTypeFromId, getTrainTypeColor } from '../utils/trainTypes';
// Fix for default marker icon
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
// Create custom train icon with train SVG design and background
const createTrainIcon = (bearing, isSelected = false, customColor = null) => {
const color = customColor || (isSelected ? '#E74C3C' : '#1a1a2e');
const hasBearing = bearing !== null && bearing !== undefined;
const rotation = hasBearing ? bearing : 0;
// Train SVG icon - scaled up and with custom color
const trainSvg = `
<svg width="20" height="28" viewBox="0 0 10 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Train window/top section -->
<path d="M3.87387 2.35688C3.60022 2.35688 3.37838 2.58903 3.37838 2.8754C3.37838 3.16177 3.60022 3.39392 3.87388 3.39392H6.12613C6.39978 3.39392 6.62162 3.16177 6.62162 2.8754C6.62162 2.58903 6.39978 2.35688 6.12613 2.35688H3.87387Z" fill="white"/>
<!-- Train front light -->
<path d="M4.72472 10.1042C4.8062 10.0472 4.902 10.0168 5 10.0168C5.13141 10.0168 5.25745 10.0715 5.35037 10.1687C5.44329 10.2659 5.4955 10.3978 5.4955 10.5353C5.4955 10.6379 5.46644 10.7382 5.41199 10.8234C5.35754 10.9087 5.28016 10.9752 5.18962 11.0144C5.09908 11.0536 4.99945 11.0639 4.90333 11.0439C4.80722 11.0239 4.71893 10.9745 4.64963 10.902C4.58034 10.8295 4.53314 10.7371 4.51403 10.6365C4.49491 10.5359 4.50472 10.4317 4.54222 10.3369C4.57972 10.2422 4.64323 10.1612 4.72472 10.1042Z" fill="white"/>
<!-- Train body outline -->
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.05522 11.6668L9.68743 13.2847C9.82082 13.6261 9.5809 14 9.22848 14C9.02714 14 8.84578 13.8726 8.76955 13.6776L8.18555 12.1836L8.12293 12.2105C7.13363 12.6366 6.07099 12.8446 5.0014 12.8215L4.99861 12.8216C3.92902 12.8446 2.86637 12.6366 1.87707 12.2105L1.81445 12.1836L1.23042 13.6776C1.15421 13.8726 0.972893 14 0.771592 14C0.419198 14 0.179324 13.6261 0.31279 13.2848L0.944797 11.6686L0.899378 11.6364C0.585747 11.4141 0.30963 11.1388 0.0823217 10.8217L0 10.6909V2.28945C0.0276524 1.98124 0.137204 1.68727 0.31628 1.44083C0.495975 1.19353 0.738459 1.00407 1.01607 0.893983L1.01797 0.893158C2.28253 0.34503 3.63187 0.042379 5 0C6.36812 0.0424441 7.71744 0.345123 8.98202 0.893189L8.98393 0.893947C9.26151 1.00409 9.50395 1.1936 9.68364 1.44089C9.86271 1.68733 9.97228 1.98127 10 2.28945V10.6907L9.91779 10.8198C9.69046 11.1369 9.4143 11.4123 9.10062 11.6346L9.05522 11.6668ZM1.10865 2.08512C1.04029 2.18656 1.00123 2.30642 0.996121 2.43043L0.996075 2.43502C1.02193 3.6747 1.37848 5.12344 2.04792 6.26436C2.71743 7.40538 3.70721 8.24915 5 8.24915C6.29279 8.24915 7.28257 7.40538 7.95208 6.26436C8.62152 5.12344 8.97822 3.6747 9.00407 2.43502L9.00388 2.43043C8.99877 2.30642 8.95971 2.18656 8.89135 2.08512C8.82307 1.98379 8.72842 1.90511 8.61866 1.85842C7.46927 1.36263 6.24485 1.08432 5.00247 1.03687L4.99754 1.03706C3.75522 1.08451 2.53085 1.3626 1.38152 1.85835C1.27168 1.90503 1.17697 1.98374 1.10865 2.08512ZM9.00901 10.3547V6.39395L8.88325 6.617C7.99932 8.18476 6.64652 9.28619 5 9.28619C3.35348 9.28619 2.00068 8.18476 1.11675 6.617L0.990991 6.39395V10.3552L1.0085 10.3754C1.33139 10.7482 2.46073 11.7845 5 11.7845C7.53929 11.7845 8.66295 10.7529 8.99143 10.3749L9.00901 10.3547Z" fill="${color}"/>
</svg>
`;
// If we have bearing data, rotate the train icon
if (hasBearing) {
return L.divIcon({
html: `
<div style="
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
border: 2px solid ${color};
">
<div style="transform: rotate(${rotation}deg);">
${trainSvg}
</div>
</div>
`,
className: 'train-marker',
iconSize: [36, 36],
iconAnchor: [18, 18],
});
}
// No bearing - show train icon without rotation
return L.divIcon({
html: `
<div style="
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
border: 2px solid ${color};
">
${trainSvg}
</div>
`,
className: 'train-marker',
iconSize: [36, 36],
iconAnchor: [18, 18],
});
};
function MapUpdater({ center, zoom }) {
const map = useMap();
React.useEffect(() => {
if (center) {
map.setView(center, zoom);
}
}, [center, zoom, map]);
return null;
}
// Get station icon size based on type
const getStationSize = (stationType) => {
switch (stationType) {
case 'MAJOR': return 20;
case 'MEDIUM': return 16;
default: return 12;
}
};
// Create Cercanías station icon
const createStationIcon = (stationType) => {
const size = getStationSize(stationType);
// Cercanías logo SVG
const stationSvg = `
<svg width="${size}" height="${size}" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<circle cx="150" cy="150" r="150" style="fill:rgb(239,44,48);"/>
<path d="M150,100C177.429,100 200,122.571 200,150C200,177.429 177.429,200 150,200C122.571,200 100,177.429 100,150L50,150C50,204.858 95.142,250 150,250C204.858,250 250,204.858 250,150C250,95.142 204.858,50 150,50L150,100Z" style="fill:white;"/>
</svg>
`;
return L.divIcon({
html: `
<div style="
width: ${size}px;
height: ${size}px;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3));
">
${stationSvg}
</div>
`,
className: 'station-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
};
export function TrainMap({ trains, selectedTrain, onTrainClick }) {
const { stations } = useStations();
const [showStations, setShowStations] = useState(true);
const center = useMemo(() => {
if (selectedTrain) {
return [selectedTrain.latitude, selectedTrain.longitude];
}
return [40.4168, -3.7038]; // Madrid center
}, [selectedTrain]);
const zoom = selectedTrain ? 12 : 6;
// Get train color based on movement status
// Green = moving (IN_TRANSIT_TO), Red = stopped (STOPPED_AT)
const getTrainColor = (train, isSelected) => {
if (isSelected) return '#9B59B6'; // Purple for selected
// Check if train is stopped or moving
const isStopped = train.status === 'STOPPED_AT' || train.status === 'INCOMING_AT';
if (isStopped) {
return '#E74C3C'; // Red for stopped
}
return '#27AE60'; // Green for moving
};
return (
<div className="map-container">
{/* Station toggle button */}
<div style={{
position: 'absolute',
top: '10px',
right: '10px',
zIndex: 1000,
background: 'white',
padding: '8px 12px',
borderRadius: '6px',
boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.85rem',
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={showStations}
onChange={(e) => setShowStations(e.target.checked)}
/>
Mostrar estaciones
</label>
</div>
<MapContainer
center={center}
zoom={zoom}
style={{ width: '100%', height: '100%' }}
zoomControl={true}
>
<MapUpdater center={selectedTrain ? center : null} zoom={zoom} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Stations layer */}
{showStations && stations.map((station) => (
<Marker
key={station.station_id}
position={[station.latitude, station.longitude]}
icon={createStationIcon(station.station_type)}
>
<Tooltip direction="top" offset={[0, -10]} permanent={false}>
<div style={{ textAlign: 'center' }}>
<strong>{station.station_name}</strong>
{station.metadata?.platforms && (
<div style={{ fontSize: '0.8rem', color: '#666' }}>
{station.metadata.platforms} andenes
</div>
)}
</div>
</Tooltip>
<Popup>
<div style={{ minWidth: '220px', maxWidth: '280px' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '1rem' }}>
{station.station_name}
</h3>
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Codigo:</strong> {station.station_code}
</p>
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Tipo:</strong> {station.station_type === 'MAJOR' ? 'Principal' : station.station_type === 'MEDIUM' ? 'Media' : 'Secundaria'}
</p>
{station.metadata?.nucleo_name && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Nucleo:</strong> {station.metadata.nucleo_name}
</p>
)}
{station.metadata?.lineas && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Lineas:</strong>{' '}
{station.metadata.lineas.split(',').map((linea, i) => (
<span key={i} style={{
display: 'inline-block',
background: '#3498DB',
color: 'white',
padding: '1px 6px',
borderRadius: '4px',
fontSize: '0.8rem',
marginRight: '4px',
marginBottom: '2px'
}}>
{linea.trim()}
</span>
))}
</p>
)}
{station.metadata?.metro && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Metro:</strong> {station.metadata.metro}
</p>
)}
{(station.metadata?.bus_urbano || station.metadata?.bus_interurbano) && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Bus:</strong>{' '}
{station.metadata.bus_urbano && <span>Urbano</span>}
{station.metadata.bus_urbano && station.metadata.bus_interurbano && ', '}
{station.metadata.bus_interurbano && <span>Interurbano</span>}
</p>
)}
{station.metadata?.parking_bicis && (
<p style={{ margin: '4px 0', fontSize: '0.85rem', color: '#27ae60' }}>
<strong>Bicis:</strong> {station.metadata.parking_bicis}
</p>
)}
{station.metadata?.accesibilidad && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Accesibilidad:</strong> {station.metadata.accesibilidad}
</p>
)}
{station.metadata?.platforms && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Andenes:</strong> {station.metadata.platforms}
</p>
)}
{station.metadata?.capacity && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Capacidad:</strong> {station.metadata.capacity}
</p>
)}
</div>
</Popup>
</Marker>
))}
{/* Trains layer */}
{trains.map((train) => {
const isSelected = selectedTrain?.train_id === train.train_id;
const trainColor = getTrainColor(train, isSelected);
// Offset train position slightly when stopped at a station
// so both station and train markers can be clicked
const isAtStation = train.status === 'STOPPED_AT' || train.codEstAct;
const offsetLat = isAtStation ? 0.0003 : 0; // ~30m offset north
const offsetLng = isAtStation ? 0.0003 : 0; // ~30m offset east
return (
<Marker
key={train.train_id}
position={[train.latitude + offsetLat, train.longitude + offsetLng]}
icon={createTrainIcon(train.bearing, isSelected, trainColor)}
eventHandlers={{
click: () => onTrainClick(train),
}}
>
<Popup>
<div style={{ minWidth: '200px' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '1rem' }}>
Tren {train.train_id}
{train.codLinea && (
<span style={{ marginLeft: '8px', padding: '2px 6px', background: '#3498DB', color: 'white', borderRadius: '4px', fontSize: '0.8rem' }}>
{train.codLinea}
</span>
)}
</h3>
{train.route_id && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Ruta:</strong> {train.route_id}
</p>
)}
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Estado:</strong> {train.status || 'N/A'}
</p>
{train.speed != null && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Velocidad:</strong> {Math.round(train.speed)} km/h
</p>
)}
{train.retrasoMin !== undefined && (
<p style={{ margin: '4px 0', fontSize: '0.9rem', color: train.retrasoMin > 0 ? '#E74C3C' : '#2ECC71', fontWeight: 'bold' }}>
{train.retrasoMin === 0 ? 'Puntual' : train.retrasoMin > 0 ? `+${train.retrasoMin} min retraso` : `${train.retrasoMin} min adelantado`}
</p>
)}
{(train.estacionSiguiente || train.codEstSig) && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Proxima:</strong> {train.estacionSiguiente || train.codEstSig}
</p>
)}
{(train.estacionDestino || train.codEstDest) && (
<p style={{ margin: '4px 0', fontSize: '0.9rem' }}>
<strong>Destino:</strong> {train.estacionDestino || train.codEstDest}
</p>
)}
<p style={{ margin: '8px 0 0 0', fontSize: '0.8rem', color: '#666' }}>
{new Date(train.timestamp).toLocaleString('es-ES')}
</p>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useState, useEffect, useCallback, useRef } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export function useDashboard() {
const [isLive, setIsLive] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [stats, setStats] = useState(null);
const [timeline, setTimeline] = useState([]);
const [linesRanking, setLinesRanking] = useState([]);
const [availableRange, setAvailableRange] = useState({ earliest: null, latest: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const refreshIntervalRef = useRef(null);
// Fetch available data range
const fetchAvailableRange = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/dashboard/available-range`);
if (!response.ok) throw new Error('Failed to fetch available range');
const data = await response.json();
setAvailableRange({
earliest: data.earliest ? new Date(data.earliest) : null,
latest: data.latest ? new Date(data.latest) : null,
});
} catch (err) {
console.error('Error fetching available range:', err);
}
}, []);
// Fetch current/live stats
const fetchCurrentStats = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/dashboard/current`);
if (!response.ok) throw new Error('Failed to fetch current stats');
const data = await response.json();
setStats(data);
setCurrentTime(new Date(data.timestamp));
setError(null);
} catch (err) {
console.error('Error fetching current stats:', err);
setError(err.message);
}
}, []);
// Fetch snapshot at specific time
const fetchSnapshotStats = useCallback(async (timestamp) => {
try {
setIsLoading(true);
const response = await fetch(`${API_URL}/dashboard/snapshot?timestamp=${timestamp.toISOString()}`);
if (!response.ok) throw new Error('Failed to fetch snapshot stats');
const data = await response.json();
setStats(data);
setError(null);
} catch (err) {
console.error('Error fetching snapshot stats:', err);
setError(err.message);
} finally {
setIsLoading(false);
}
}, []);
// Fetch timeline data
const fetchTimeline = useCallback(async (start, end, interval = 5) => {
try {
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
interval: interval.toString(),
});
const response = await fetch(`${API_URL}/dashboard/timeline?${params}`);
if (!response.ok) throw new Error('Failed to fetch timeline');
const data = await response.json();
setTimeline(data.data);
} catch (err) {
console.error('Error fetching timeline:', err);
}
}, []);
// Fetch lines ranking
const fetchLinesRanking = useCallback(async (timestamp, hours = 24) => {
try {
const params = new URLSearchParams({
timestamp: timestamp.toISOString(),
hours: hours.toString(),
});
const response = await fetch(`${API_URL}/dashboard/lines-ranking?${params}`);
if (!response.ok) throw new Error('Failed to fetch lines ranking');
const data = await response.json();
setLinesRanking(data);
} catch (err) {
console.error('Error fetching lines ranking:', err);
}
}, []);
// Seek to specific time
const seekTo = useCallback((timestamp) => {
setIsLive(false);
setCurrentTime(timestamp);
fetchSnapshotStats(timestamp);
// Fetch timeline for 2 hours around the timestamp
const start = new Date(timestamp.getTime() - 3600000);
const end = new Date(timestamp.getTime() + 3600000);
fetchTimeline(start, end, 5);
fetchLinesRanking(timestamp, 24);
}, [fetchSnapshotStats, fetchTimeline, fetchLinesRanking]);
// Go live
const goLive = useCallback(() => {
setIsLive(true);
setCurrentTime(new Date());
fetchCurrentStats();
// Fetch last hour timeline
const now = new Date();
const start = new Date(now.getTime() - 3600000);
fetchTimeline(start, now, 5);
fetchLinesRanking(now, 24);
}, [fetchCurrentStats, fetchTimeline, fetchLinesRanking]);
// Skip forward/backward
const skip = useCallback((minutes) => {
const newTime = new Date(currentTime.getTime() + minutes * 60000);
seekTo(newTime);
}, [currentTime, seekTo]);
// Initial load
useEffect(() => {
const init = async () => {
setIsLoading(true);
await fetchAvailableRange();
await fetchCurrentStats();
const now = new Date();
const start = new Date(now.getTime() - 3600000);
await fetchTimeline(start, now, 5);
await fetchLinesRanking(now, 24);
setIsLoading(false);
};
init();
}, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking]);
// Auto-refresh when live
useEffect(() => {
if (isLive) {
refreshIntervalRef.current = setInterval(() => {
fetchCurrentStats();
const now = new Date();
const start = new Date(now.getTime() - 3600000);
fetchTimeline(start, now, 5);
}, 10000); // Refresh every 10 seconds
} else {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
refreshIntervalRef.current = null;
}
}
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
}
};
}, [isLive, fetchCurrentStats, fetchTimeline]);
return {
isLive,
currentTime,
stats,
timeline,
linesRanking,
availableRange,
isLoading,
error,
seekTo,
goLive,
skip,
setIsLive,
};
}

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export function useStations() {
const [stations, setStations] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchStations = async () => {
try {
const response = await fetch(`${API_URL}/stations`);
if (!response.ok) {
throw new Error('Failed to fetch stations');
}
const data = await response.json();
// Convert lat/lon to numbers
const stationsWithNumbers = data.map(station => ({
...station,
latitude: parseFloat(station.latitude),
longitude: parseFloat(station.longitude),
}));
setStations(stationsWithNumbers);
setError(null);
} catch (err) {
console.error('Error fetching stations:', err);
setError(err.message);
} finally {
setIsLoading(false);
}
};
fetchStations();
}, []);
return {
stations,
isLoading,
error,
};
}

View File

@@ -0,0 +1,221 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { calculateBearing, calculateDistance, MIN_DISTANCE_FOR_BEARING } from '../utils/bearing';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
/**
* Pre-calculate bearings for historical data based on consecutive positions
* @param {Array} positions - Array of positions sorted by timestamp ASC
* @returns {Array} - Positions with calculated bearings
*/
function calculateHistoricalBearings(positions) {
// Group positions by train_id
const trainPositions = new Map();
for (const pos of positions) {
if (!trainPositions.has(pos.train_id)) {
trainPositions.set(pos.train_id, []);
}
trainPositions.get(pos.train_id).push(pos);
}
// Calculate bearings for each train's positions
const result = [];
for (const [trainId, trainPos] of trainPositions) {
// Sort by timestamp (should already be sorted, but ensure)
trainPos.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
for (let i = 0; i < trainPos.length; i++) {
const current = trainPos[i];
let bearing = current.bearing; // Use existing bearing if available
if (bearing === null || bearing === undefined) {
// Calculate bearing from previous position
if (i > 0) {
const prev = trainPos[i - 1];
const distance = calculateDistance(prev.latitude, prev.longitude, current.latitude, current.longitude);
if (distance >= MIN_DISTANCE_FOR_BEARING) {
bearing = calculateBearing(prev.latitude, prev.longitude, current.latitude, current.longitude);
}
}
}
result.push({
...current,
bearing,
});
}
}
// Re-sort by timestamp to maintain chronological order
result.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
return result;
}
export function useTimeline() {
const [isTimelineMode, setIsTimelineMode] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(Date.now());
const [historyData, setHistoryData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1); // 1x, 2x, 5x, 10x
const playIntervalRef = useRef(null);
// Time range: last hour by default
const [timeRange, setTimeRange] = useState({
start: Date.now() - 3600000, // 1 hour ago
end: Date.now(),
});
// Load all historical positions for the time range
const loadAllHistory = useCallback(async () => {
setIsLoading(true);
try {
const startISO = new Date(timeRange.start).toISOString();
const endISO = new Date(timeRange.end).toISOString();
// Fetch all positions in the time range with a single request
const response = await fetch(
`${API_URL}/trains/history/all?from=${startISO}&to=${endISO}&limit=10000`
);
if (!response.ok) {
console.error('Failed to fetch historical data');
setIsLoading(false);
return;
}
const allHistory = await response.json();
// Calculate bearings based on consecutive positions
const historyWithBearings = calculateHistoricalBearings(allHistory);
setHistoryData(historyWithBearings);
console.log(`Loaded ${historyWithBearings.length} historical positions with bearings calculated`);
} catch (err) {
console.error('Error loading history:', err);
}
setIsLoading(false);
}, [timeRange]);
// Get positions at a specific time
const getPositionsAtTime = useCallback((timestamp) => {
if (historyData.length === 0) return [];
// Group by train_id and get the closest position to the timestamp
const trainPositions = new Map();
for (const position of historyData) {
const posTime = new Date(position.timestamp).getTime();
// Only consider positions up to the current playback time
if (posTime <= timestamp) {
const existing = trainPositions.get(position.train_id);
if (!existing || new Date(existing.timestamp).getTime() < posTime) {
// latitude/longitude already come as numbers from the API
trainPositions.set(position.train_id, position);
}
}
}
return Array.from(trainPositions.values());
}, [historyData]);
// Toggle timeline mode
const toggleTimelineMode = useCallback(() => {
if (!isTimelineMode) {
// Entering timeline mode
setIsTimelineMode(true);
setCurrentTime(timeRange.start);
loadAllHistory();
} else {
// Exiting timeline mode
setIsTimelineMode(false);
setIsPlaying(false);
setHistoryData([]);
}
}, [isTimelineMode, timeRange.start, loadAllHistory]);
// Play/pause
const togglePlay = useCallback(() => {
setIsPlaying(prev => !prev);
}, []);
// Skip forward/backward
const skip = useCallback((seconds) => {
setCurrentTime(prev => {
const newTime = prev + (seconds * 1000);
return Math.max(timeRange.start, Math.min(timeRange.end, newTime));
});
}, [timeRange]);
// Seek to specific time
const seekTo = useCallback((timestamp) => {
setCurrentTime(timestamp);
setIsPlaying(false);
}, []);
// Change playback speed
const changeSpeed = useCallback((speed) => {
setPlaybackSpeed(speed);
}, []);
// Update time range
const updateTimeRange = useCallback((start, end) => {
setTimeRange({ start, end });
setCurrentTime(start);
if (isTimelineMode) {
loadAllHistory();
}
}, [isTimelineMode, loadAllHistory]);
// Playback effect
useEffect(() => {
if (isPlaying && isTimelineMode) {
playIntervalRef.current = setInterval(() => {
setCurrentTime(prev => {
const next = prev + (1000 * playbackSpeed); // Advance based on speed
if (next >= timeRange.end) {
setIsPlaying(false);
return timeRange.end;
}
return next;
});
}, 100); // Update every 100ms for smooth animation
} else {
if (playIntervalRef.current) {
clearInterval(playIntervalRef.current);
playIntervalRef.current = null;
}
}
return () => {
if (playIntervalRef.current) {
clearInterval(playIntervalRef.current);
}
};
}, [isPlaying, isTimelineMode, playbackSpeed, timeRange.end]);
// Get current positions based on mode
const timelinePositions = isTimelineMode ? getPositionsAtTime(currentTime) : [];
return {
isTimelineMode,
isPlaying,
isLoading,
currentTime,
timeRange,
playbackSpeed,
timelinePositions,
historyData,
toggleTimelineMode,
togglePlay,
skip,
seekTo,
changeSpeed,
updateTimeRange,
};
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { io } from 'socket.io-client';
import { calculateBearing, calculateDistance, MIN_DISTANCE_FOR_BEARING } from '../utils/bearing';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
// Calculate bearing for trains based on previous positions
function addCalculatedBearings(newTrains, previousPositions) {
return newTrains.map(train => {
// If train already has bearing from API, use it
if (train.bearing !== null && train.bearing !== undefined) {
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
return train;
}
const prevPos = previousPositions.get(train.train_id);
let calculatedBearing = null;
if (prevPos) {
const distance = calculateDistance(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
// Only calculate bearing if the train moved enough
if (distance >= MIN_DISTANCE_FOR_BEARING) {
calculatedBearing = calculateBearing(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
}
}
// Update previous position
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
return {
...train,
bearing: calculatedBearing,
};
});
}
export function useTrains() {
const [trains, setTrains] = useState([]);
const [selectedTrain, setSelectedTrain] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
const [stats, setStats] = useState({
active_trains: 0,
last_update: null,
});
const socketRef = useRef(null);
// Store previous positions to calculate bearing
const previousPositionsRef = useRef(new Map());
// Initialize WebSocket connection
useEffect(() => {
console.log('Connecting to WebSocket:', WS_URL);
const socket = io(WS_URL, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
});
socket.on('connect', () => {
console.log('WebSocket connected');
setIsConnected(true);
setError(null);
});
socket.on('disconnect', () => {
console.log('WebSocket disconnected');
setIsConnected(false);
});
socket.on('connect_error', (err) => {
console.error('WebSocket connection error:', err);
setError(err.message);
setIsConnected(false);
});
socket.on('trains:update', (positions) => {
console.log('Received train updates:', positions.length);
const trainsWithBearing = addCalculatedBearings(positions, previousPositionsRef.current);
setTrains(trainsWithBearing);
});
socket.on('train:update', (position) => {
console.log('Received individual train update:', position.train_id);
setTrains((prev) => {
const [updatedPosition] = addCalculatedBearings([position], previousPositionsRef.current);
const index = prev.findIndex(t => t.train_id === position.train_id);
if (index >= 0) {
const updated = [...prev];
updated[index] = updatedPosition;
return updated;
}
return [...prev, updatedPosition];
});
});
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, []);
// Fetch initial data
useEffect(() => {
const fetchInitialData = async () => {
try {
const response = await fetch(`${API_URL}/trains/current`);
if (!response.ok) {
throw new Error('Failed to fetch trains');
}
const data = await response.json();
console.log('Fetched initial trains:', data.length);
// Store initial positions (no bearing calculation for first load)
data.forEach(train => {
previousPositionsRef.current.set(train.train_id, {
lat: train.latitude,
lon: train.longitude,
});
});
setTrains(data);
} catch (err) {
console.error('Error fetching initial data:', err);
setError(err.message);
}
};
fetchInitialData();
}, []);
// Fetch stats periodically
useEffect(() => {
const fetchStats = async () => {
try {
const response = await fetch(`${API_URL}/stats`);
if (!response.ok) return;
const data = await response.json();
setStats(data);
} catch (err) {
console.error('Error fetching stats:', err);
}
};
fetchStats();
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
// Subscribe to specific train
const subscribeTrain = useCallback((trainId) => {
if (socketRef.current && trainId) {
socketRef.current.emit('subscribe:train', trainId);
}
}, []);
// Unsubscribe from specific train
const unsubscribeTrain = useCallback((trainId) => {
if (socketRef.current && trainId) {
socketRef.current.emit('unsubscribe:train', trainId);
}
}, []);
// Fetch full train details
const fetchTrainDetails = useCallback(async (trainId) => {
try {
const response = await fetch(`${API_URL}/trains/${trainId}`);
if (!response.ok) return null;
return await response.json();
} catch (err) {
console.error('Error fetching train details:', err);
return null;
}
}, []);
// Select train and fetch full details
const selectTrain = useCallback(async (train) => {
if (selectedTrain) {
unsubscribeTrain(selectedTrain.train_id);
}
if (train) {
subscribeTrain(train.train_id);
// Fetch full train details including type, service name, etc.
const details = await fetchTrainDetails(train.train_id);
if (details) {
// Merge position data with full train details
// Keep all fleet data from the original train object (codLinea, estaciones, etc.)
setSelectedTrain({
...train,
train_type: details.train_type,
service_name: details.service_name,
first_seen: details.first_seen,
last_seen: details.last_seen,
metadata: details.metadata,
// Also merge fleet_data if available from details
...(details.fleet_data && {
codLinea: details.fleet_data.codLinea,
retrasoMin: details.fleet_data.retrasoMin,
codEstAct: details.fleet_data.codEstAct,
estacionActual: details.fleet_data.estacionActual,
codEstSig: details.fleet_data.codEstSig,
estacionSiguiente: details.fleet_data.estacionSiguiente,
horaLlegadaSigEst: details.fleet_data.horaLlegadaSigEst,
codEstDest: details.fleet_data.codEstDest,
estacionDestino: details.fleet_data.estacionDestino,
codEstOrig: details.fleet_data.codEstOrig,
estacionOrigen: details.fleet_data.estacionOrigen,
nucleo: details.fleet_data.nucleo,
accesible: details.fleet_data.accesible,
via: details.fleet_data.via,
}),
});
return;
}
}
setSelectedTrain(train);
}, [selectedTrain, subscribeTrain, unsubscribeTrain, fetchTrainDetails]);
return {
trains,
selectedTrain,
selectTrain,
isConnected,
error,
stats,
};
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,857 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
#root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.header {
height: 60px;
background: #1a1a2e;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.header-title {
font-size: 1.5rem;
font-weight: bold;
display: flex;
align-items: center;
gap: 10px;
}
.header-stats {
display: flex;
gap: 20px;
font-size: 0.9rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.stat-label {
opacity: 0.7;
font-size: 0.8rem;
}
.stat-value {
font-weight: bold;
font-size: 1.1rem;
}
.main-content {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.map-container {
flex: 1;
position: relative;
}
.leaflet-container {
width: 100%;
height: 100%;
}
.sidebar {
width: 350px;
background: white;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 15px 20px;
border-bottom: 1px solid #e0e0e0;
background: #f5f5f5;
}
.sidebar-header h2 {
font-size: 1.2rem;
margin-bottom: 5px;
}
.sidebar-subtitle {
font-size: 0.85rem;
color: #666;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.train-info {
display: flex;
flex-direction: column;
gap: 15px;
}
.info-section {
background: #f9f9f9;
padding: 15px;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.info-section h3 {
font-size: 1rem;
margin-bottom: 10px;
color: #333;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-size: 1rem;
font-weight: 500;
color: #333;
margin-top: 2px;
}
.timeline-container {
position: absolute;
bottom: 20px;
left: 20px;
right: 370px;
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.timeline-title {
font-size: 1rem;
font-weight: bold;
color: #333;
}
.timeline-controls {
display: flex;
gap: 10px;
}
.timeline-btn {
background: #1a1a2e;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 5px;
transition: background 0.2s;
}
.timeline-btn:hover {
background: #2a2a3e;
}
.timeline-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.timeline-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #1a1a2e;
cursor: pointer;
}
.timeline-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #1a1a2e;
cursor: pointer;
border: none;
}
.timeline-time {
text-align: center;
margin-top: 10px;
font-size: 0.9rem;
color: #666;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
font-size: 1.2rem;
color: #666;
}
.error {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-direction: column;
gap: 10px;
color: #d32f2f;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-direction: column;
gap: 10px;
color: #666;
font-size: 1rem;
}
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.status-indicator.active {
background: #4caf50;
box-shadow: 0 0 4px #4caf50;
}
.status-indicator.inactive {
background: #9e9e9e;
}
.status-indicator.timeline {
background: #ff9800;
box-shadow: 0 0 4px #ff9800;
}
/* Timeline Styles */
.timeline-realtime {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-realtime-info {
display: flex;
align-items: center;
gap: 8px;
color: #666;
}
.timeline-title-section {
display: flex;
align-items: center;
gap: 10px;
}
.timeline-btn-primary {
background: #ff9800;
}
.timeline-btn-primary:hover {
background: #f57c00;
}
.timeline-btn-close {
background: transparent;
color: #666;
padding: 6px;
}
.timeline-btn-close:hover {
background: #f0f0f0;
color: #333;
}
.timeline-btn-play {
background: #4caf50;
padding: 8px 20px;
}
.timeline-btn-play:hover {
background: #43a047;
}
.timeline-slider-container {
position: relative;
margin: 15px 0;
}
.timeline-progress {
position: absolute;
top: 0;
left: 0;
height: 6px;
background: #ff9800;
border-radius: 3px;
pointer-events: none;
}
.timeline-time-display {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.timeline-time-label {
color: #999;
}
.timeline-time-current {
font-size: 1.1rem;
font-weight: bold;
color: #ff9800;
}
.timeline-speed {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid #e0e0e0;
}
.timeline-speed span {
font-size: 0.85rem;
color: #666;
}
.timeline-speed select {
padding: 4px 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: white;
font-size: 0.85rem;
cursor: pointer;
}
.timeline-loading {
animation: spin 1s linear infinite;
}
.timeline-loading-message {
text-align: center;
color: #999;
font-size: 0.85rem;
margin-top: 10px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Navigation */
.nav-tabs {
display: flex;
gap: 5px;
background: rgba(255, 255, 255, 0.1);
padding: 4px;
border-radius: 8px;
}
.nav-tab {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.nav-tab:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.nav-tab.active {
background: white;
color: #1a1a2e;
}
/* Dashboard Styles */
.dashboard {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f5f6fa;
}
.dashboard-loading,
.dashboard-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 15px;
color: #666;
}
.dashboard-error {
color: #E74C3C;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e0e0e0;
border-top-color: #3498DB;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Time Control */
.time-control {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.time-control-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 15px;
}
.time-display {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.1rem;
color: #333;
}
.current-time {
font-weight: 600;
}
.live-badge {
display: flex;
align-items: center;
gap: 5px;
background: #E74C3C;
color: white;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
animation: pulse-live 2s ease-in-out infinite;
}
@keyframes pulse-live {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.time-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.time-buttons button {
background: #f0f0f0;
border: none;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
color: #333;
}
.time-buttons button:hover {
background: #e0e0e0;
}
.time-buttons .live-button {
background: #E74C3C;
color: white;
}
.time-buttons .live-button:hover {
background: #c0392b;
}
.time-buttons .live-button.active {
background: #27AE60;
}
.time-slider-container {
margin-top: 10px;
}
.time-slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
.time-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #3498DB;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.time-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #3498DB;
cursor: pointer;
border: none;
}
.time-range-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #999;
margin-top: 5px;
}
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: flex-start;
gap: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-card-icon {
width: 50px;
height: 50px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-card-content {
display: flex;
flex-direction: column;
}
.stat-card-label {
font-size: 0.85rem;
color: #666;
}
.stat-card-value {
font-size: 1.8rem;
font-weight: 700;
color: #333;
line-height: 1.2;
}
.stat-card-subvalue {
font-size: 0.75rem;
color: #999;
}
.stat-card-trend {
font-size: 0.85rem;
font-weight: 600;
}
.stat-card-trend.positive {
color: #27AE60;
}
.stat-card-trend.negative {
color: #E74C3C;
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
}
.dashboard-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.dashboard-card.wide {
grid-column: 1 / -1;
}
.dashboard-card h3 {
font-size: 1rem;
color: #333;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
/* Progress Bars */
.progress-bar-container {
margin-bottom: 12px;
}
.progress-bar-header {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.progress-bar-label {
font-size: 0.85rem;
color: #666;
}
.progress-bar-value {
font-size: 0.85rem;
font-weight: 600;
color: #333;
}
.progress-bar-track {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
/* Donut Chart */
.donut-chart {
display: flex;
align-items: center;
gap: 20px;
}
.donut-chart svg {
width: 120px;
height: 120px;
flex-shrink: 0;
}
.donut-legend {
flex: 1;
}
.donut-legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 0.85rem;
}
.donut-legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.donut-legend-label {
flex: 1;
color: #666;
}
.donut-legend-value {
font-weight: 600;
color: #333;
}
/* Timeline Charts */
.timeline-charts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.timeline-chart {
text-align: center;
}
.timeline-chart .chart-label {
font-size: 0.8rem;
color: #666;
margin-bottom: 5px;
display: block;
}
/* Lines Table */
.lines-table {
font-size: 0.85rem;
}
.lines-table-header {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
padding: 10px 0;
border-bottom: 2px solid #e0e0e0;
font-weight: 600;
color: #666;
}
.lines-table-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
align-items: center;
}
.lines-table-row:hover {
background: #f9f9f9;
}
.line-code {
font-weight: 600;
color: #3498DB;
}
.line-nucleo {
font-weight: 400;
color: #999;
font-size: 0.8em;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
position: absolute;
right: 0;
top: 0;
bottom: 0;
z-index: 1001;
transform: translateX(100%);
transition: transform 0.3s;
}
.sidebar.open {
transform: translateX(0);
}
.timeline-container {
right: 20px;
}
}

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
}

14
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
},
preview: {
port: 5173,
host: true,
},
});