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
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:
69
frontend/Dockerfile
Normal file
69
frontend/Dockerfile
Normal 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
16
frontend/index.html
Normal 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
44
frontend/nginx.conf
Normal 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
31
frontend/package.json
Normal 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
188
frontend/src/App.jsx
Normal 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);
|
||||
447
frontend/src/components/Dashboard.jsx
Normal file
447
frontend/src/components/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/Timeline.jsx
Normal file
175
frontend/src/components/Timeline.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
frontend/src/components/TrainInfo.jsx
Normal file
310
frontend/src/components/TrainInfo.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
361
frontend/src/components/TrainMap.jsx
Normal file
361
frontend/src/components/TrainMap.jsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
183
frontend/src/hooks/useDashboard.js
Normal file
183
frontend/src/hooks/useDashboard.js
Normal 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,
|
||||
};
|
||||
}
|
||||
44
frontend/src/hooks/useStations.js
Normal file
44
frontend/src/hooks/useStations.js
Normal 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,
|
||||
};
|
||||
}
|
||||
221
frontend/src/hooks/useTimeline.js
Normal file
221
frontend/src/hooks/useTimeline.js
Normal 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,
|
||||
};
|
||||
}
|
||||
230
frontend/src/hooks/useTrains.js
Normal file
230
frontend/src/hooks/useTrains.js
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
);
|
||||
857
frontend/src/styles/index.css
Normal file
857
frontend/src/styles/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
53
frontend/src/utils/bearing.js
Normal file
53
frontend/src/utils/bearing.js
Normal 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;
|
||||
126
frontend/src/utils/trainTypes.js
Normal file
126
frontend/src/utils/trainTypes.js
Normal 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
14
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user