Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
093b75d775 | ||
|
|
f979ad67a0 | ||
|
|
436a7e25d4 | ||
|
|
24a08e405f | ||
|
|
c9e6c1970c |
@@ -20,13 +20,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: backend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
npm ci
|
npm install
|
||||||
|
|
||||||
- name: Run ESLint (backend)
|
- name: Run ESLint (backend)
|
||||||
run: |
|
run: |
|
||||||
@@ -48,13 +46,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm ci
|
npm install
|
||||||
|
|
||||||
- name: Run ESLint (frontend)
|
- name: Run ESLint (frontend)
|
||||||
run: |
|
run: |
|
||||||
@@ -72,13 +68,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm ci
|
npm install
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ jobs:
|
|||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ github.server_url }}
|
registry: tea.millaguie.net
|
||||||
username: ${{ github.actor }}
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push backend image
|
- name: Build and push backend image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -52,10 +52,11 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
target: production
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_API_URL=${{ secrets.PROD_API_URL }}
|
VITE_API_URL=${{ secrets.PROD_API_URL || 'https://trenes.millaguie.net/api' }}
|
||||||
VITE_WS_URL=${{ secrets.PROD_WS_URL }}
|
VITE_WS_URL=${{ secrets.PROD_WS_URL || 'https://trenes.millaguie.net' }}
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
APP_VERSION=${{ steps.version.outputs.version }}
|
||||||
BUILD_DATE=${{ steps.date.outputs.date }}
|
BUILD_DATE=${{ steps.date.outputs.date }}
|
||||||
GIT_COMMIT=${{ github.sha }}
|
GIT_COMMIT=${{ github.sha }}
|
||||||
|
|||||||
@@ -344,4 +344,428 @@ router.get('/available-range', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /dashboard/lines - Get list of all lines with basic stats
|
||||||
|
router.get('/lines', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { hours = 24 } = req.query;
|
||||||
|
const startTime = new Date(Date.now() - hours * 3600000);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
line_code,
|
||||||
|
nucleo,
|
||||||
|
COUNT(DISTINCT train_id) as unique_trains,
|
||||||
|
COUNT(*) as observations,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND line_code IS NOT NULL
|
||||||
|
GROUP BY line_code, nucleo
|
||||||
|
HAVING COUNT(*) >= 5
|
||||||
|
ORDER BY unique_trains DESC
|
||||||
|
`, [startTime]);
|
||||||
|
|
||||||
|
const linesWithNucleoName = result.rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
nucleo_name: NUCLEO_NAMES[row.nucleo] || row.nucleo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(linesWithNucleoName);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /dashboard/regions - Get list of all regions with stats
|
||||||
|
router.get('/regions', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { hours = 24 } = req.query;
|
||||||
|
const startTime = new Date(Date.now() - hours * 3600000);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
nucleo,
|
||||||
|
COUNT(DISTINCT line_code) as line_count,
|
||||||
|
COUNT(DISTINCT train_id) as unique_trains,
|
||||||
|
COUNT(*) as observations,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
MAX(delay_minutes) as max_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND nucleo IS NOT NULL
|
||||||
|
GROUP BY nucleo
|
||||||
|
ORDER BY unique_trains DESC
|
||||||
|
`, [startTime]);
|
||||||
|
|
||||||
|
const regionsWithName = result.rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
nucleo_name: NUCLEO_NAMES[row.nucleo] || row.nucleo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(regionsWithName);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /dashboard/line/:lineCode - Get detailed stats for a specific line
|
||||||
|
router.get('/line/:lineCode', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { lineCode } = req.params;
|
||||||
|
const { nucleo, hours = 24 } = req.query;
|
||||||
|
const startTime = new Date(Date.now() - hours * 3600000);
|
||||||
|
|
||||||
|
// Basic stats for the line
|
||||||
|
const statsResult = await db.query(`
|
||||||
|
SELECT
|
||||||
|
line_code,
|
||||||
|
nucleo,
|
||||||
|
COUNT(DISTINCT train_id) as unique_trains,
|
||||||
|
COUNT(*) as observations,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
MAX(delay_minutes) as max_delay,
|
||||||
|
MIN(delay_minutes) as min_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct,
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 0 THEN 1 END) as on_time,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 0 AND delay_minutes <= 5 THEN 1 END) as minor_delay,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 5 AND delay_minutes <= 15 THEN 1 END) as moderate_delay,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 15 THEN 1 END) as severe_delay
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND line_code = $2
|
||||||
|
${nucleo ? 'AND nucleo = $3' : ''}
|
||||||
|
GROUP BY line_code, nucleo
|
||||||
|
`, nucleo ? [startTime, lineCode, nucleo] : [startTime, lineCode]);
|
||||||
|
|
||||||
|
if (statsResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Line not found or no data available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline for this line
|
||||||
|
const timelineResult = await db.query(`
|
||||||
|
WITH time_buckets AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('hour', recorded_at) as time_bucket,
|
||||||
|
train_id,
|
||||||
|
delay_minutes
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND line_code = $2
|
||||||
|
${nucleo ? 'AND nucleo = $3' : ''}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
time_bucket,
|
||||||
|
COUNT(DISTINCT train_id) as train_count,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct
|
||||||
|
FROM time_buckets
|
||||||
|
GROUP BY time_bucket
|
||||||
|
ORDER BY time_bucket
|
||||||
|
`, nucleo ? [startTime, lineCode, nucleo] : [startTime, lineCode]);
|
||||||
|
|
||||||
|
// Top stations for this line
|
||||||
|
const stationsResult = await db.query(`
|
||||||
|
SELECT
|
||||||
|
origin_station_code as station_code,
|
||||||
|
COUNT(DISTINCT train_id) as train_count,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND line_code = $2
|
||||||
|
${nucleo ? 'AND nucleo = $3' : ''}
|
||||||
|
AND origin_station_code IS NOT NULL
|
||||||
|
GROUP BY origin_station_code
|
||||||
|
ORDER BY train_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
`, nucleo ? [startTime, lineCode, nucleo] : [startTime, lineCode]);
|
||||||
|
|
||||||
|
const stats = statsResult.rows[0];
|
||||||
|
res.json({
|
||||||
|
line_code: lineCode,
|
||||||
|
nucleo: stats.nucleo,
|
||||||
|
nucleo_name: NUCLEO_NAMES[stats.nucleo] || stats.nucleo,
|
||||||
|
stats: {
|
||||||
|
unique_trains: parseInt(stats.unique_trains, 10),
|
||||||
|
observations: parseInt(stats.observations, 10),
|
||||||
|
avg_delay: parseFloat(stats.avg_delay) || 0,
|
||||||
|
max_delay: parseInt(stats.max_delay, 10),
|
||||||
|
min_delay: parseInt(stats.min_delay, 10),
|
||||||
|
punctuality_pct: parseFloat(stats.punctuality_pct) || 0,
|
||||||
|
punctuality_breakdown: {
|
||||||
|
on_time: parseInt(stats.on_time, 10),
|
||||||
|
minor_delay: parseInt(stats.minor_delay, 10),
|
||||||
|
moderate_delay: parseInt(stats.moderate_delay, 10),
|
||||||
|
severe_delay: parseInt(stats.severe_delay, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeline: timelineResult.rows.map(row => ({
|
||||||
|
timestamp: row.time_bucket,
|
||||||
|
train_count: parseInt(row.train_count, 10),
|
||||||
|
avg_delay: parseFloat(row.avg_delay) || 0,
|
||||||
|
punctuality_pct: parseFloat(row.punctuality_pct) || 0,
|
||||||
|
})),
|
||||||
|
top_stations: stationsResult.rows.map(row => ({
|
||||||
|
station_code: row.station_code,
|
||||||
|
train_count: parseInt(row.train_count, 10),
|
||||||
|
avg_delay: parseFloat(row.avg_delay) || 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /dashboard/region/:nucleo - Get detailed stats for a specific region
|
||||||
|
router.get('/region/:nucleo', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { nucleo } = req.params;
|
||||||
|
const { hours = 24 } = req.query;
|
||||||
|
const startTime = new Date(Date.now() - hours * 3600000);
|
||||||
|
|
||||||
|
// Basic stats for the region
|
||||||
|
const statsResult = await db.query(`
|
||||||
|
SELECT
|
||||||
|
nucleo,
|
||||||
|
COUNT(DISTINCT line_code) as line_count,
|
||||||
|
COUNT(DISTINCT train_id) as unique_trains,
|
||||||
|
COUNT(*) as observations,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
MAX(delay_minutes) as max_delay,
|
||||||
|
MIN(delay_minutes) as min_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct,
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 0 THEN 1 END) as on_time,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 0 AND delay_minutes <= 5 THEN 1 END) as minor_delay,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 5 AND delay_minutes <= 15 THEN 1 END) as moderate_delay,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 15 THEN 1 END) as severe_delay
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND nucleo = $2
|
||||||
|
GROUP BY nucleo
|
||||||
|
`, [startTime, nucleo]);
|
||||||
|
|
||||||
|
if (statsResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Region not found or no data available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline for this region
|
||||||
|
const timelineResult = await db.query(`
|
||||||
|
WITH time_buckets AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('hour', recorded_at) as time_bucket,
|
||||||
|
train_id,
|
||||||
|
delay_minutes
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND nucleo = $2
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
time_bucket,
|
||||||
|
COUNT(DISTINCT train_id) as train_count,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct
|
||||||
|
FROM time_buckets
|
||||||
|
GROUP BY time_bucket
|
||||||
|
ORDER BY time_bucket
|
||||||
|
`, [startTime, nucleo]);
|
||||||
|
|
||||||
|
// Lines in this region
|
||||||
|
const linesResult = await db.query(`
|
||||||
|
SELECT
|
||||||
|
line_code,
|
||||||
|
COUNT(DISTINCT train_id) as unique_trains,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at >= $1
|
||||||
|
AND nucleo = $2
|
||||||
|
AND line_code IS NOT NULL
|
||||||
|
GROUP BY line_code
|
||||||
|
ORDER BY unique_trains DESC
|
||||||
|
LIMIT 15
|
||||||
|
`, [startTime, nucleo]);
|
||||||
|
|
||||||
|
const stats = statsResult.rows[0];
|
||||||
|
res.json({
|
||||||
|
nucleo,
|
||||||
|
nucleo_name: NUCLEO_NAMES[nucleo] || nucleo,
|
||||||
|
stats: {
|
||||||
|
line_count: parseInt(stats.line_count, 10),
|
||||||
|
unique_trains: parseInt(stats.unique_trains, 10),
|
||||||
|
observations: parseInt(stats.observations, 10),
|
||||||
|
avg_delay: parseFloat(stats.avg_delay) || 0,
|
||||||
|
max_delay: parseInt(stats.max_delay, 10),
|
||||||
|
min_delay: parseInt(stats.min_delay, 10),
|
||||||
|
punctuality_pct: parseFloat(stats.punctuality_pct) || 0,
|
||||||
|
punctuality_breakdown: {
|
||||||
|
on_time: parseInt(stats.on_time, 10),
|
||||||
|
minor_delay: parseInt(stats.minor_delay, 10),
|
||||||
|
moderate_delay: parseInt(stats.moderate_delay, 10),
|
||||||
|
severe_delay: parseInt(stats.severe_delay, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeline: timelineResult.rows.map(row => ({
|
||||||
|
timestamp: row.time_bucket,
|
||||||
|
train_count: parseInt(row.train_count, 10),
|
||||||
|
avg_delay: parseFloat(row.avg_delay) || 0,
|
||||||
|
punctuality_pct: parseFloat(row.punctuality_pct) || 0,
|
||||||
|
})),
|
||||||
|
lines: linesResult.rows.map(row => ({
|
||||||
|
line_code: row.line_code,
|
||||||
|
unique_trains: parseInt(row.unique_trains, 10),
|
||||||
|
avg_delay: parseFloat(row.avg_delay) || 0,
|
||||||
|
punctuality_pct: parseFloat(row.punctuality_pct) || 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /dashboard/compare - Compare stats between two date ranges
|
||||||
|
router.get('/compare', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { start1, end1, start2, end2, lineCode, nucleo } = req.query;
|
||||||
|
|
||||||
|
if (!start1 || !end1 || !start2 || !end2) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Required parameters: start1, end1, start2, end2'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const range1Start = new Date(start1);
|
||||||
|
const range1End = new Date(end1);
|
||||||
|
const range2Start = new Date(start2);
|
||||||
|
const range2End = new Date(end2);
|
||||||
|
|
||||||
|
// Build WHERE clause based on filters
|
||||||
|
let filterClause = '';
|
||||||
|
const params1 = [range1Start, range1End];
|
||||||
|
const params2 = [range2Start, range2End];
|
||||||
|
|
||||||
|
if (lineCode) {
|
||||||
|
filterClause += ` AND line_code = $3`;
|
||||||
|
params1.push(lineCode);
|
||||||
|
params2.push(lineCode);
|
||||||
|
}
|
||||||
|
if (nucleo) {
|
||||||
|
const nucleoParamIndex = lineCode ? 4 : 3;
|
||||||
|
filterClause += ` AND nucleo = $${nucleoParamIndex}`;
|
||||||
|
params1.push(nucleo);
|
||||||
|
params2.push(nucleo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryText = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT train_id) as unique_trains,
|
||||||
|
COUNT(*) as observations,
|
||||||
|
AVG(delay_minutes)::FLOAT as avg_delay,
|
||||||
|
MAX(delay_minutes) as max_delay,
|
||||||
|
ROUND(
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 5 THEN 1 END)::NUMERIC /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 1
|
||||||
|
) as punctuality_pct,
|
||||||
|
COUNT(CASE WHEN delay_minutes <= 0 THEN 1 END) as on_time,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 0 AND delay_minutes <= 5 THEN 1 END) as minor_delay,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 5 AND delay_minutes <= 15 THEN 1 END) as moderate_delay,
|
||||||
|
COUNT(CASE WHEN delay_minutes > 15 THEN 1 END) as severe_delay
|
||||||
|
FROM train_punctuality
|
||||||
|
WHERE recorded_at BETWEEN $1 AND $2
|
||||||
|
${filterClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [range1Result, range2Result] = await Promise.all([
|
||||||
|
db.query(queryText, params1),
|
||||||
|
db.query(queryText, params2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const range1Stats = range1Result.rows[0] || {};
|
||||||
|
const range2Stats = range2Result.rows[0] || {};
|
||||||
|
|
||||||
|
// Calculate differences
|
||||||
|
const calculateDiff = (val1, val2) => {
|
||||||
|
if (!val1 || !val2) return null;
|
||||||
|
return parseFloat(val1) - parseFloat(val2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculatePctChange = (val1, val2) => {
|
||||||
|
if (!val1 || !val2 || parseFloat(val2) === 0) return null;
|
||||||
|
return ((parseFloat(val1) - parseFloat(val2)) / parseFloat(val2) * 100).toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
range1: {
|
||||||
|
start: range1Start.toISOString(),
|
||||||
|
end: range1End.toISOString(),
|
||||||
|
stats: {
|
||||||
|
unique_trains: parseInt(range1Stats.unique_trains, 10) || 0,
|
||||||
|
observations: parseInt(range1Stats.observations, 10) || 0,
|
||||||
|
avg_delay: parseFloat(range1Stats.avg_delay) || 0,
|
||||||
|
max_delay: parseInt(range1Stats.max_delay, 10) || 0,
|
||||||
|
punctuality_pct: parseFloat(range1Stats.punctuality_pct) || 0,
|
||||||
|
punctuality_breakdown: {
|
||||||
|
on_time: parseInt(range1Stats.on_time, 10) || 0,
|
||||||
|
minor_delay: parseInt(range1Stats.minor_delay, 10) || 0,
|
||||||
|
moderate_delay: parseInt(range1Stats.moderate_delay, 10) || 0,
|
||||||
|
severe_delay: parseInt(range1Stats.severe_delay, 10) || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
range2: {
|
||||||
|
start: range2Start.toISOString(),
|
||||||
|
end: range2End.toISOString(),
|
||||||
|
stats: {
|
||||||
|
unique_trains: parseInt(range2Stats.unique_trains, 10) || 0,
|
||||||
|
observations: parseInt(range2Stats.observations, 10) || 0,
|
||||||
|
avg_delay: parseFloat(range2Stats.avg_delay) || 0,
|
||||||
|
max_delay: parseInt(range2Stats.max_delay, 10) || 0,
|
||||||
|
punctuality_pct: parseFloat(range2Stats.punctuality_pct) || 0,
|
||||||
|
punctuality_breakdown: {
|
||||||
|
on_time: parseInt(range2Stats.on_time, 10) || 0,
|
||||||
|
minor_delay: parseInt(range2Stats.minor_delay, 10) || 0,
|
||||||
|
moderate_delay: parseInt(range2Stats.moderate_delay, 10) || 0,
|
||||||
|
severe_delay: parseInt(range2Stats.severe_delay, 10) || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
comparison: {
|
||||||
|
unique_trains_diff: calculateDiff(range1Stats.unique_trains, range2Stats.unique_trains),
|
||||||
|
avg_delay_diff: calculateDiff(range1Stats.avg_delay, range2Stats.avg_delay),
|
||||||
|
punctuality_diff: calculateDiff(range1Stats.punctuality_pct, range2Stats.punctuality_pct),
|
||||||
|
unique_trains_pct_change: calculatePctChange(range1Stats.unique_trains, range2Stats.unique_trains),
|
||||||
|
avg_delay_pct_change: calculatePctChange(range1Stats.avg_delay, range2Stats.avg_delay),
|
||||||
|
punctuality_pct_change: calculatePctChange(range1Stats.punctuality_pct, range2Stats.punctuality_pct),
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
line_code: lineCode || null,
|
||||||
|
nucleo: nucleo || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
# Docker Compose para producción
|
# Docker Compose para producción
|
||||||
# Uso: docker compose -f docker-compose.prod.yml up -d --build
|
# Uso: docker compose -f docker-compose.prod.yml up -d
|
||||||
#
|
#
|
||||||
# Requisitos previos:
|
# Requisitos previos:
|
||||||
# 1. Configurar .env con valores de producción (ver .env.example)
|
# 1. Configurar .env con valores de producción (ver .env.example)
|
||||||
# 2. Configurar certificados SSL en ./nginx/ssl/ o usar certbot
|
# 2. Login al registry: docker login tea.millaguie.net
|
||||||
# 3. Configurar nginx/prod.conf con tu dominio
|
# 3. Configurar nginx/prod.conf con tu dominio
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Base de datos PostgreSQL con extensión PostGIS
|
# Base de datos PostgreSQL con extensión PostGIS
|
||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:16-3.4-alpine # IMPORTANTE: usar versión 16 para compatibilidad
|
image: postgis/postgis:16-3.4-alpine
|
||||||
container_name: trenes-postgres
|
container_name: trenes-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -68,17 +66,42 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- trenes-network
|
- trenes-network
|
||||||
profiles:
|
|
||||||
- migration
|
# API Backend
|
||||||
|
api:
|
||||||
|
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||||
|
container_name: trenes-api
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "src/api/server.js"]
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGINS:-https://localhost}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change_me_in_production}
|
||||||
|
LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
flyway:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- trenes-network
|
||||||
|
|
||||||
# Worker para polling GTFS-RT Vehicle Positions
|
# Worker para polling GTFS-RT Vehicle Positions
|
||||||
worker:
|
worker:
|
||||||
build:
|
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: worker
|
|
||||||
container_name: trenes-worker
|
container_name: trenes-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: ["node", "src/worker/gtfs-poller.js"]
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||||
@@ -91,24 +114,23 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
flyway:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- trenes-network
|
- trenes-network
|
||||||
|
|
||||||
# Worker para sincronización GTFS Static
|
# Worker para sincronización GTFS Static
|
||||||
gtfs-static-syncer:
|
gtfs-static-syncer:
|
||||||
build:
|
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: worker
|
|
||||||
container_name: trenes-gtfs-static-syncer
|
container_name: trenes-gtfs-static-syncer
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: node src/worker/gtfs-static-syncer.js
|
command: ["node", "src/worker/gtfs-static-syncer.js"]
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
GTFS_STATIC_URL: https://data.renfe.com/dataset/horarios-trenes-largo-recorrido-ave/resource/horarios-trenes-largo-recorrido-ave-gtfs.zip
|
GTFS_STATIC_URL: https://data.renfe.com/dataset/horarios-trenes-largo-recorrido-ave/resource/horarios-trenes-largo-recorrido-ave-gtfs.zip
|
||||||
SYNC_SCHEDULE: 0 3 * * *
|
SYNC_SCHEDULE: "0 3 * * *"
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
volumes:
|
volumes:
|
||||||
- gtfs_static_data:/tmp/gtfs
|
- gtfs_static_data:/tmp/gtfs
|
||||||
@@ -117,18 +139,17 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
flyway:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- trenes-network
|
- trenes-network
|
||||||
|
|
||||||
# Worker para polling GTFS-RT Trip Updates
|
# Worker para polling GTFS-RT Trip Updates
|
||||||
trip-updates-poller:
|
trip-updates-poller:
|
||||||
build:
|
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: worker
|
|
||||||
container_name: trenes-trip-updates-poller
|
container_name: trenes-trip-updates-poller
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: node src/worker/trip-updates-poller.js
|
command: ["node", "src/worker/trip-updates-poller.js"]
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||||
@@ -141,18 +162,17 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
flyway:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- trenes-network
|
- trenes-network
|
||||||
|
|
||||||
# Worker para polling GTFS-RT Service Alerts
|
# Worker para polling GTFS-RT Service Alerts
|
||||||
alerts-poller:
|
alerts-poller:
|
||||||
build:
|
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: worker
|
|
||||||
container_name: trenes-alerts-poller
|
container_name: trenes-alerts-poller
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: node src/worker/alerts-poller.js
|
command: ["node", "src/worker/alerts-poller.js"]
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||||
@@ -165,18 +185,17 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
flyway:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- trenes-network
|
- trenes-network
|
||||||
|
|
||||||
# Worker para datos de flota Renfe
|
# Worker para datos de flota Renfe
|
||||||
renfe-fleet-poller:
|
renfe-fleet-poller:
|
||||||
build:
|
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: worker
|
|
||||||
container_name: trenes-renfe-fleet-poller
|
container_name: trenes-renfe-fleet-poller
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: node src/worker/renfe-fleet-poller.js
|
command: ["node", "src/worker/renfe-fleet-poller.js"]
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||||
@@ -187,18 +206,17 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
flyway:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- trenes-network
|
- trenes-network
|
||||||
|
|
||||||
# Worker para refrescar vistas de analytics
|
# Worker para refrescar vistas de analytics
|
||||||
analytics-refresher:
|
analytics-refresher:
|
||||||
build:
|
image: tea.millaguie.net/millaguie/trenes-backend:${IMAGE_TAG:-latest}
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: worker
|
|
||||||
container_name: trenes-analytics-refresher
|
container_name: trenes-analytics-refresher
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: node src/worker/analytics-refresher.js
|
command: ["node", "src/worker/analytics-refresher.js"]
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
||||||
@@ -210,49 +228,14 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
flyway:
|
||||||
- trenes-network
|
condition: service_completed_successfully
|
||||||
|
|
||||||
# API Backend
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: api
|
|
||||||
container_name: trenes-api
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3000
|
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-trenes}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-trenes}
|
|
||||||
REDIS_URL: redis://redis:6379
|
|
||||||
CORS_ORIGIN: ${CORS_ORIGINS:-https://localhost}
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
LOG_LEVEL: info
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
networks:
|
||||||
- trenes-network
|
- trenes-network
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
# IMPORTANTE: Las variables VITE_* deben pasarse como build args, no como environment
|
|
||||||
# ya que se procesan en tiempo de compilación, no en runtime
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: tea.millaguie.net/millaguie/trenes-frontend:${IMAGE_TAG:-latest}
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: production
|
|
||||||
args:
|
|
||||||
VITE_API_URL: ${VITE_API_URL}
|
|
||||||
VITE_WS_URL: ${VITE_WS_URL}
|
|
||||||
container_name: trenes-frontend
|
container_name: trenes-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -267,7 +250,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- letsencrypt_certs:/etc/letsencrypt:ro
|
- certbot_certs:/etc/letsencrypt:ro
|
||||||
- certbot_webroot:/var/www/certbot:ro
|
- certbot_webroot:/var/www/certbot:ro
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
@@ -288,7 +271,7 @@ services:
|
|||||||
image: certbot/certbot
|
image: certbot/certbot
|
||||||
container_name: trenes-certbot
|
container_name: trenes-certbot
|
||||||
volumes:
|
volumes:
|
||||||
- letsencrypt_certs:/etc/letsencrypt
|
- certbot_certs:/etc/letsencrypt
|
||||||
- certbot_webroot:/var/www/certbot
|
- certbot_webroot:/var/www/certbot
|
||||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
networks:
|
networks:
|
||||||
@@ -301,10 +284,10 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
gtfs_static_data:
|
gtfs_static_data:
|
||||||
driver: local
|
driver: local
|
||||||
letsencrypt_certs:
|
certbot_certs:
|
||||||
driver: local
|
external: true
|
||||||
certbot_webroot:
|
certbot_webroot:
|
||||||
driver: local
|
external: true
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
trenes-network:
|
trenes-network:
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ function App() {
|
|||||||
{activeView === 'dashboard'
|
{activeView === 'dashboard'
|
||||||
? 'Dashboard de Trenes'
|
? 'Dashboard de Trenes'
|
||||||
: isTimelineMode
|
: isTimelineMode
|
||||||
? 'Reproduccion Historica - Espana'
|
? 'Reproduccion Historica - España'
|
||||||
: 'Trenes en Tiempo Real - Espana'}
|
: 'Trenes en Tiempo Real - España'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -12,8 +12,12 @@ import {
|
|||||||
SkipForward,
|
SkipForward,
|
||||||
Radio,
|
Radio,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
ArrowLeft,
|
||||||
|
GitCompare,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useDashboard } from '../hooks/useDashboard';
|
import { useDashboard, VIEW_MODES } from '../hooks/useDashboard';
|
||||||
|
|
||||||
// Mini chart component for timeline
|
// Mini chart component for timeline
|
||||||
function MiniChart({ data, dataKey, color, height = 60 }) {
|
function MiniChart({ data, dataKey, color, height = 60 }) {
|
||||||
@@ -153,7 +157,7 @@ function PunctualityDonut({ data }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lines ranking table
|
// Lines ranking table
|
||||||
function LinesTable({ lines }) {
|
function LinesTable({ lines, onLineClick }) {
|
||||||
if (!lines || lines.length === 0) {
|
if (!lines || lines.length === 0) {
|
||||||
return <div className="empty-state">Sin datos de lineas</div>;
|
return <div className="empty-state">Sin datos de lineas</div>;
|
||||||
}
|
}
|
||||||
@@ -167,7 +171,11 @@ function LinesTable({ lines }) {
|
|||||||
<span>Puntualidad</span>
|
<span>Puntualidad</span>
|
||||||
</div>
|
</div>
|
||||||
{lines.slice(0, 10).map((line, index) => (
|
{lines.slice(0, 10).map((line, index) => (
|
||||||
<div key={`${line.nucleo}:${line.line_code}-${index}`} className="lines-table-row">
|
<div
|
||||||
|
key={`${line.nucleo}:${line.line_code}-${index}`}
|
||||||
|
className={`lines-table-row ${onLineClick ? 'clickable' : ''}`}
|
||||||
|
onClick={() => onLineClick && onLineClick(line.line_code, line.nucleo)}
|
||||||
|
>
|
||||||
<span className="line-code">
|
<span className="line-code">
|
||||||
{line.line_code}
|
{line.line_code}
|
||||||
{line.nucleo_name && <span className="line-nucleo"> ({line.nucleo_name})</span>}
|
{line.nucleo_name && <span className="line-nucleo"> ({line.nucleo_name})</span>}
|
||||||
@@ -185,6 +193,510 @@ function LinesTable({ lines }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View mode tabs
|
||||||
|
function ViewTabs({ viewMode, onChangeView, onCompareClick }) {
|
||||||
|
return (
|
||||||
|
<div className="view-tabs">
|
||||||
|
<button
|
||||||
|
className={`view-tab ${viewMode === VIEW_MODES.GENERAL ? 'active' : ''}`}
|
||||||
|
onClick={() => onChangeView(VIEW_MODES.GENERAL)}
|
||||||
|
>
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
General
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-tab ${viewMode === VIEW_MODES.LINE ? 'active' : ''}`}
|
||||||
|
onClick={() => onChangeView(VIEW_MODES.LINE)}
|
||||||
|
>
|
||||||
|
<Train size={16} />
|
||||||
|
Por Linea
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-tab ${viewMode === VIEW_MODES.REGION ? 'active' : ''}`}
|
||||||
|
onClick={() => onChangeView(VIEW_MODES.REGION)}
|
||||||
|
>
|
||||||
|
<MapPin size={16} />
|
||||||
|
Por Region
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`view-tab ${viewMode === VIEW_MODES.COMPARE ? 'active' : ''}`}
|
||||||
|
onClick={onCompareClick}
|
||||||
|
>
|
||||||
|
<GitCompare size={16} />
|
||||||
|
Comparar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line selector dropdown
|
||||||
|
function LineSelector({ lines, selectedLine, onSelect }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedLabel = selectedLine
|
||||||
|
? `${selectedLine.lineCode} (${lines.find(l => l.line_code === selectedLine.lineCode && l.nucleo === selectedLine.nucleo)?.nucleo_name || ''})`
|
||||||
|
: 'Seleccionar linea...';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="selector-container">
|
||||||
|
<button className="selector-button" onClick={() => setIsOpen(!isOpen)}>
|
||||||
|
<Train size={16} />
|
||||||
|
{selectedLabel}
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="selector-dropdown">
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<div
|
||||||
|
key={`${line.nucleo}:${line.line_code}-${index}`}
|
||||||
|
className="selector-option"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(line.line_code, line.nucleo);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="line-code">{line.line_code}</span>
|
||||||
|
<span className="line-nucleo">{line.nucleo_name}</span>
|
||||||
|
<span className="line-trains">{line.unique_trains} trenes</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region selector dropdown
|
||||||
|
function RegionSelector({ regions, selectedRegion, onSelect }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedLabel = selectedRegion
|
||||||
|
? regions.find(r => r.nucleo === selectedRegion)?.nucleo_name || selectedRegion
|
||||||
|
: 'Seleccionar region...';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="selector-container">
|
||||||
|
<button className="selector-button" onClick={() => setIsOpen(!isOpen)}>
|
||||||
|
<MapPin size={16} />
|
||||||
|
{selectedLabel}
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="selector-dropdown">
|
||||||
|
{regions.map((region) => (
|
||||||
|
<div
|
||||||
|
key={region.nucleo}
|
||||||
|
className="selector-option"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(region.nucleo);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="region-name">{region.nucleo_name}</span>
|
||||||
|
<span className="region-lines">{region.line_count} lineas</span>
|
||||||
|
<span className="region-trains">{region.unique_trains} trenes</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line detail view
|
||||||
|
function LineDetailView({ lineDetails, onBack }) {
|
||||||
|
if (!lineDetails) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-view">
|
||||||
|
<div className="detail-header">
|
||||||
|
<button className="back-button" onClick={onBack}>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Volver
|
||||||
|
</button>
|
||||||
|
<h2>
|
||||||
|
<Train size={24} />
|
||||||
|
Linea {lineDetails.line_code}
|
||||||
|
<span className="detail-subtitle">{lineDetails.nucleo_name}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-row">
|
||||||
|
<StatCard
|
||||||
|
icon={Train}
|
||||||
|
label="Trenes Unicos"
|
||||||
|
value={lineDetails.stats.unique_trains}
|
||||||
|
color="#3498DB"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CheckCircle}
|
||||||
|
label="Puntualidad"
|
||||||
|
value={`${lineDetails.stats.punctuality_pct}%`}
|
||||||
|
color="#27AE60"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Retraso Medio"
|
||||||
|
value={`${lineDetails.stats.avg_delay.toFixed(1)} min`}
|
||||||
|
color={lineDetails.stats.avg_delay > 5 ? '#E74C3C' : '#27AE60'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label="Retraso Maximo"
|
||||||
|
value={`${lineDetails.stats.max_delay} min`}
|
||||||
|
color="#E74C3C"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<div className="dashboard-card">
|
||||||
|
<h3>
|
||||||
|
<Clock size={18} />
|
||||||
|
Distribucion de Puntualidad
|
||||||
|
</h3>
|
||||||
|
<PunctualityDonut data={lineDetails.stats.punctuality_breakdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-card wide">
|
||||||
|
<h3>
|
||||||
|
<TrendingUp size={18} />
|
||||||
|
Evolucion Temporal
|
||||||
|
</h3>
|
||||||
|
<div className="timeline-charts">
|
||||||
|
<div className="timeline-chart">
|
||||||
|
<span className="chart-label">Trenes</span>
|
||||||
|
<MiniChart data={lineDetails.timeline} dataKey="train_count" color="#3498DB" height={50} />
|
||||||
|
</div>
|
||||||
|
<div className="timeline-chart">
|
||||||
|
<span className="chart-label">Puntualidad %</span>
|
||||||
|
<MiniChart data={lineDetails.timeline} dataKey="punctuality_pct" color="#27AE60" height={50} />
|
||||||
|
</div>
|
||||||
|
<div className="timeline-chart">
|
||||||
|
<span className="chart-label">Retraso Medio</span>
|
||||||
|
<MiniChart data={lineDetails.timeline} dataKey="avg_delay" color="#E74C3C" height={50} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lineDetails.top_stations && lineDetails.top_stations.length > 0 && (
|
||||||
|
<div className="dashboard-card">
|
||||||
|
<h3>
|
||||||
|
<MapPin size={18} />
|
||||||
|
Estaciones Principales
|
||||||
|
</h3>
|
||||||
|
<div className="stations-list">
|
||||||
|
{lineDetails.top_stations.map((station, index) => (
|
||||||
|
<div key={station.station_code} className="station-item">
|
||||||
|
<span className="station-rank">#{index + 1}</span>
|
||||||
|
<span className="station-code">{station.station_code}</span>
|
||||||
|
<span className="station-trains">{station.train_count} trenes</span>
|
||||||
|
<span
|
||||||
|
className="station-delay"
|
||||||
|
style={{ color: station.avg_delay > 5 ? '#E74C3C' : '#27AE60' }}
|
||||||
|
>
|
||||||
|
{station.avg_delay.toFixed(1)} min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region detail view
|
||||||
|
function RegionDetailView({ regionDetails, onBack, onLineClick }) {
|
||||||
|
if (!regionDetails) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-view">
|
||||||
|
<div className="detail-header">
|
||||||
|
<button className="back-button" onClick={onBack}>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Volver
|
||||||
|
</button>
|
||||||
|
<h2>
|
||||||
|
<MapPin size={24} />
|
||||||
|
{regionDetails.nucleo_name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-row">
|
||||||
|
<StatCard
|
||||||
|
icon={BarChart3}
|
||||||
|
label="Lineas"
|
||||||
|
value={regionDetails.stats.line_count}
|
||||||
|
color="#9B59B6"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Train}
|
||||||
|
label="Trenes Unicos"
|
||||||
|
value={regionDetails.stats.unique_trains}
|
||||||
|
color="#3498DB"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CheckCircle}
|
||||||
|
label="Puntualidad"
|
||||||
|
value={`${regionDetails.stats.punctuality_pct}%`}
|
||||||
|
color="#27AE60"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Retraso Medio"
|
||||||
|
value={`${regionDetails.stats.avg_delay.toFixed(1)} min`}
|
||||||
|
color={regionDetails.stats.avg_delay > 5 ? '#E74C3C' : '#27AE60'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<div className="dashboard-card">
|
||||||
|
<h3>
|
||||||
|
<Clock size={18} />
|
||||||
|
Distribucion de Puntualidad
|
||||||
|
</h3>
|
||||||
|
<PunctualityDonut data={regionDetails.stats.punctuality_breakdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-card wide">
|
||||||
|
<h3>
|
||||||
|
<TrendingUp size={18} />
|
||||||
|
Evolucion Temporal
|
||||||
|
</h3>
|
||||||
|
<div className="timeline-charts">
|
||||||
|
<div className="timeline-chart">
|
||||||
|
<span className="chart-label">Trenes</span>
|
||||||
|
<MiniChart data={regionDetails.timeline} dataKey="train_count" color="#3498DB" height={50} />
|
||||||
|
</div>
|
||||||
|
<div className="timeline-chart">
|
||||||
|
<span className="chart-label">Puntualidad %</span>
|
||||||
|
<MiniChart data={regionDetails.timeline} dataKey="punctuality_pct" color="#27AE60" height={50} />
|
||||||
|
</div>
|
||||||
|
<div className="timeline-chart">
|
||||||
|
<span className="chart-label">Retraso Medio</span>
|
||||||
|
<MiniChart data={regionDetails.timeline} dataKey="avg_delay" color="#E74C3C" height={50} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-card">
|
||||||
|
<h3>
|
||||||
|
<Train size={18} />
|
||||||
|
Lineas en la Region
|
||||||
|
</h3>
|
||||||
|
<div className="region-lines-list">
|
||||||
|
{regionDetails.lines.map((line) => (
|
||||||
|
<div
|
||||||
|
key={line.line_code}
|
||||||
|
className="region-line-item clickable"
|
||||||
|
onClick={() => onLineClick(line.line_code, regionDetails.nucleo)}
|
||||||
|
>
|
||||||
|
<span className="line-code">{line.line_code}</span>
|
||||||
|
<span className="line-trains">{line.unique_trains} trenes</span>
|
||||||
|
<span
|
||||||
|
className="line-punctuality"
|
||||||
|
style={{ color: line.punctuality_pct >= 80 ? '#27AE60' : '#E74C3C' }}
|
||||||
|
>
|
||||||
|
{line.punctuality_pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range picker
|
||||||
|
function DateRangePicker({ label, range, onChange }) {
|
||||||
|
const formatDateForInput = (date) => {
|
||||||
|
if (!date) return '';
|
||||||
|
return date.toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="date-range-picker">
|
||||||
|
<label>{label}</label>
|
||||||
|
<div className="date-range-inputs">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formatDateForInput(range.start)}
|
||||||
|
onChange={(e) => onChange({ ...range, start: new Date(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<span>a</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formatDateForInput(range.end)}
|
||||||
|
onChange={(e) => onChange({ ...range, end: new Date(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare view
|
||||||
|
function CompareView({ compareData, onBack, availableRange }) {
|
||||||
|
if (!compareData) return null;
|
||||||
|
|
||||||
|
const { range1, range2, comparison } = compareData;
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDiff = (diff, isPositiveGood = true) => {
|
||||||
|
if (diff === null) return <span className="diff-neutral">-</span>;
|
||||||
|
const isPositive = diff > 0;
|
||||||
|
const isGood = isPositiveGood ? isPositive : !isPositive;
|
||||||
|
return (
|
||||||
|
<span className={`diff-value ${isGood ? 'positive' : 'negative'}`}>
|
||||||
|
{isPositive ? '+' : ''}{typeof diff === 'number' ? diff.toFixed(1) : diff}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-view">
|
||||||
|
<div className="detail-header">
|
||||||
|
<button className="back-button" onClick={onBack}>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Volver
|
||||||
|
</button>
|
||||||
|
<h2>
|
||||||
|
<GitCompare size={24} />
|
||||||
|
Comparacion de Periodos
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-ranges-info">
|
||||||
|
<div className="compare-range-label">
|
||||||
|
<strong>Periodo 1:</strong> {formatDate(range1.start)} - {formatDate(range1.end)}
|
||||||
|
</div>
|
||||||
|
<div className="compare-range-label">
|
||||||
|
<strong>Periodo 2:</strong> {formatDate(range2.start)} - {formatDate(range2.end)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-grid">
|
||||||
|
<div className="compare-card">
|
||||||
|
<h3>Trenes Unicos</h3>
|
||||||
|
<div className="compare-values">
|
||||||
|
<div className="compare-value">
|
||||||
|
<span className="compare-label">Periodo 1</span>
|
||||||
|
<span className="compare-number">{range1.stats.unique_trains}</span>
|
||||||
|
</div>
|
||||||
|
<div className="compare-diff">
|
||||||
|
{renderDiff(comparison.unique_trains_diff, true)}
|
||||||
|
{comparison.unique_trains_pct_change && (
|
||||||
|
<span className="pct-change">({comparison.unique_trains_pct_change}%)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="compare-value">
|
||||||
|
<span className="compare-label">Periodo 2</span>
|
||||||
|
<span className="compare-number">{range2.stats.unique_trains}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-card">
|
||||||
|
<h3>Puntualidad</h3>
|
||||||
|
<div className="compare-values">
|
||||||
|
<div className="compare-value">
|
||||||
|
<span className="compare-label">Periodo 1</span>
|
||||||
|
<span className="compare-number">{range1.stats.punctuality_pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="compare-diff">
|
||||||
|
{renderDiff(comparison.punctuality_diff, true)}
|
||||||
|
{comparison.punctuality_pct_change && (
|
||||||
|
<span className="pct-change">({comparison.punctuality_pct_change}%)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="compare-value">
|
||||||
|
<span className="compare-label">Periodo 2</span>
|
||||||
|
<span className="compare-number">{range2.stats.punctuality_pct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-card">
|
||||||
|
<h3>Retraso Medio</h3>
|
||||||
|
<div className="compare-values">
|
||||||
|
<div className="compare-value">
|
||||||
|
<span className="compare-label">Periodo 1</span>
|
||||||
|
<span className="compare-number">{range1.stats.avg_delay.toFixed(1)} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="compare-diff">
|
||||||
|
{renderDiff(comparison.avg_delay_diff, false)}
|
||||||
|
{comparison.avg_delay_pct_change && (
|
||||||
|
<span className="pct-change">({comparison.avg_delay_pct_change}%)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="compare-value">
|
||||||
|
<span className="compare-label">Periodo 2</span>
|
||||||
|
<span className="compare-number">{range2.stats.avg_delay.toFixed(1)} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-card wide">
|
||||||
|
<h3>Distribucion de Puntualidad</h3>
|
||||||
|
<div className="compare-punctuality">
|
||||||
|
<div className="compare-punctuality-side">
|
||||||
|
<h4>Periodo 1</h4>
|
||||||
|
<PunctualityDonut data={range1.stats.punctuality_breakdown} />
|
||||||
|
</div>
|
||||||
|
<div className="compare-punctuality-side">
|
||||||
|
<h4>Periodo 2</h4>
|
||||||
|
<PunctualityDonut data={range2.stats.punctuality_breakdown} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare setup modal
|
||||||
|
function CompareSetup({ availableRange, onStartCompare, onCancel }) {
|
||||||
|
const [range1, setRange1] = useState({
|
||||||
|
start: new Date(Date.now() - 7 * 24 * 3600000),
|
||||||
|
end: new Date(),
|
||||||
|
});
|
||||||
|
const [range2, setRange2] = useState({
|
||||||
|
start: new Date(Date.now() - 14 * 24 * 3600000),
|
||||||
|
end: new Date(Date.now() - 7 * 24 * 3600000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (range1.start && range1.end && range2.start && range2.end) {
|
||||||
|
onStartCompare(range1, range2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compare-setup">
|
||||||
|
<h3>
|
||||||
|
<GitCompare size={20} />
|
||||||
|
Configurar Comparacion
|
||||||
|
</h3>
|
||||||
|
<DateRangePicker label="Periodo 1 (actual)" range={range1} onChange={setRange1} />
|
||||||
|
<DateRangePicker label="Periodo 2 (anterior)" range={range2} onChange={setRange2} />
|
||||||
|
<div className="compare-setup-actions">
|
||||||
|
<button className="btn-secondary" onClick={onCancel}>Cancelar</button>
|
||||||
|
<button className="btn-primary" onClick={handleSubmit}>Comparar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Time control bar
|
// Time control bar
|
||||||
function TimeControl({ currentTime, isLive, availableRange, onSeek, onGoLive, onSkip }) {
|
function TimeControl({ currentTime, isLive, availableRange, onSeek, onGoLive, onSkip }) {
|
||||||
const formatTime = (date) => {
|
const formatTime = (date) => {
|
||||||
@@ -282,14 +794,49 @@ export function Dashboard() {
|
|||||||
seekTo,
|
seekTo,
|
||||||
goLive,
|
goLive,
|
||||||
skip,
|
skip,
|
||||||
|
// New view states
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
allLines,
|
||||||
|
allRegions,
|
||||||
|
selectedLine,
|
||||||
|
selectedRegion,
|
||||||
|
lineDetails,
|
||||||
|
regionDetails,
|
||||||
|
compareData,
|
||||||
|
// New view actions
|
||||||
|
selectLine,
|
||||||
|
selectRegion,
|
||||||
|
startCompare,
|
||||||
|
goToGeneral,
|
||||||
} = useDashboard();
|
} = useDashboard();
|
||||||
|
|
||||||
|
const [showCompareSetup, setShowCompareSetup] = useState(false);
|
||||||
|
|
||||||
// Calculate status totals
|
// Calculate status totals
|
||||||
const statusTotal = useMemo(() => {
|
const statusTotal = useMemo(() => {
|
||||||
if (!stats?.status_breakdown) return 0;
|
if (!stats?.status_breakdown) return 0;
|
||||||
return Object.values(stats.status_breakdown).reduce((a, b) => a + b, 0);
|
return Object.values(stats.status_breakdown).reduce((a, b) => a + b, 0);
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
||||||
|
const handleViewChange = (newView) => {
|
||||||
|
if (newView === VIEW_MODES.GENERAL) {
|
||||||
|
goToGeneral();
|
||||||
|
} else if (newView === VIEW_MODES.LINE || newView === VIEW_MODES.REGION) {
|
||||||
|
// Clear any existing selection and switch to selection view
|
||||||
|
setViewMode(newView);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompareClick = () => {
|
||||||
|
setShowCompareSetup(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartCompare = (range1, range2) => {
|
||||||
|
setShowCompareSetup(false);
|
||||||
|
startCompare(range1, range2);
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-error">
|
<div className="dashboard-error">
|
||||||
@@ -300,8 +847,186 @@ export function Dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render compare setup modal
|
||||||
|
if (showCompareSetup) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<ViewTabs
|
||||||
|
viewMode={viewMode}
|
||||||
|
onChangeView={handleViewChange}
|
||||||
|
onCompareClick={handleCompareClick}
|
||||||
|
/>
|
||||||
|
<CompareSetup
|
||||||
|
availableRange={availableRange}
|
||||||
|
onStartCompare={handleStartCompare}
|
||||||
|
onCancel={() => setShowCompareSetup(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render line detail view
|
||||||
|
if (viewMode === VIEW_MODES.LINE && lineDetails) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<ViewTabs
|
||||||
|
viewMode={viewMode}
|
||||||
|
onChangeView={handleViewChange}
|
||||||
|
onCompareClick={handleCompareClick}
|
||||||
|
/>
|
||||||
|
<LineDetailView lineDetails={lineDetails} onBack={goToGeneral} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render region detail view
|
||||||
|
if (viewMode === VIEW_MODES.REGION && regionDetails) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<ViewTabs
|
||||||
|
viewMode={viewMode}
|
||||||
|
onChangeView={handleViewChange}
|
||||||
|
onCompareClick={handleCompareClick}
|
||||||
|
/>
|
||||||
|
<RegionDetailView
|
||||||
|
regionDetails={regionDetails}
|
||||||
|
onBack={goToGeneral}
|
||||||
|
onLineClick={selectLine}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render compare view
|
||||||
|
if (viewMode === VIEW_MODES.COMPARE && compareData) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<ViewTabs
|
||||||
|
viewMode={viewMode}
|
||||||
|
onChangeView={handleViewChange}
|
||||||
|
onCompareClick={handleCompareClick}
|
||||||
|
/>
|
||||||
|
<CompareView
|
||||||
|
compareData={compareData}
|
||||||
|
onBack={goToGeneral}
|
||||||
|
availableRange={availableRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render line selection view
|
||||||
|
if (viewMode === VIEW_MODES.LINE && !lineDetails) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<ViewTabs
|
||||||
|
viewMode={viewMode}
|
||||||
|
onChangeView={handleViewChange}
|
||||||
|
onCompareClick={handleCompareClick}
|
||||||
|
/>
|
||||||
|
<div className="selection-view">
|
||||||
|
<h2>
|
||||||
|
<Train size={24} />
|
||||||
|
Seleccionar Linea
|
||||||
|
</h2>
|
||||||
|
<LineSelector
|
||||||
|
lines={allLines}
|
||||||
|
selectedLine={selectedLine}
|
||||||
|
onSelect={selectLine}
|
||||||
|
/>
|
||||||
|
{allLines.length > 0 && (
|
||||||
|
<div className="selection-grid">
|
||||||
|
{allLines.slice(0, 12).map((line, index) => (
|
||||||
|
<div
|
||||||
|
key={`${line.nucleo}:${line.line_code}-${index}`}
|
||||||
|
className="selection-card clickable"
|
||||||
|
onClick={() => selectLine(line.line_code, line.nucleo)}
|
||||||
|
>
|
||||||
|
<div className="selection-card-header">
|
||||||
|
<span className="line-code">{line.line_code}</span>
|
||||||
|
<span className="line-nucleo">{line.nucleo_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="selection-card-stats">
|
||||||
|
<div className="mini-stat">
|
||||||
|
<Train size={14} />
|
||||||
|
{line.unique_trains} trenes
|
||||||
|
</div>
|
||||||
|
<div className="mini-stat" style={{ color: line.punctuality_pct >= 80 ? '#27AE60' : '#E74C3C' }}>
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
{line.punctuality_pct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render region selection view
|
||||||
|
if (viewMode === VIEW_MODES.REGION && !regionDetails) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<ViewTabs
|
||||||
|
viewMode={viewMode}
|
||||||
|
onChangeView={handleViewChange}
|
||||||
|
onCompareClick={handleCompareClick}
|
||||||
|
/>
|
||||||
|
<div className="selection-view">
|
||||||
|
<h2>
|
||||||
|
<MapPin size={24} />
|
||||||
|
Seleccionar Region
|
||||||
|
</h2>
|
||||||
|
<RegionSelector
|
||||||
|
regions={allRegions}
|
||||||
|
selectedRegion={selectedRegion}
|
||||||
|
onSelect={selectRegion}
|
||||||
|
/>
|
||||||
|
{allRegions.length > 0 && (
|
||||||
|
<div className="selection-grid">
|
||||||
|
{allRegions.map((region) => (
|
||||||
|
<div
|
||||||
|
key={region.nucleo}
|
||||||
|
className="selection-card clickable"
|
||||||
|
onClick={() => selectRegion(region.nucleo)}
|
||||||
|
>
|
||||||
|
<div className="selection-card-header">
|
||||||
|
<span className="region-name">{region.nucleo_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="selection-card-stats">
|
||||||
|
<div className="mini-stat">
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
{region.line_count} lineas
|
||||||
|
</div>
|
||||||
|
<div className="mini-stat">
|
||||||
|
<Train size={14} />
|
||||||
|
{region.unique_trains} trenes
|
||||||
|
</div>
|
||||||
|
<div className="mini-stat" style={{ color: region.punctuality_pct >= 80 ? '#27AE60' : '#E74C3C' }}>
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
{region.punctuality_pct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render general view (default)
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
<ViewTabs
|
||||||
|
viewMode={viewMode}
|
||||||
|
onChangeView={handleViewChange}
|
||||||
|
onCompareClick={handleCompareClick}
|
||||||
|
/>
|
||||||
|
|
||||||
<TimeControl
|
<TimeControl
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
@@ -437,7 +1162,7 @@ export function Dashboard() {
|
|||||||
<AlertTriangle size={18} />
|
<AlertTriangle size={18} />
|
||||||
Ranking de Lineas (Peor Puntualidad)
|
Ranking de Lineas (Peor Puntualidad)
|
||||||
</h3>
|
</h3>
|
||||||
<LinesTable lines={linesRanking} />
|
<LinesTable lines={linesRanking} onLineClick={selectLine} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
// View modes
|
||||||
|
export const VIEW_MODES = {
|
||||||
|
GENERAL: 'general',
|
||||||
|
LINE: 'line',
|
||||||
|
REGION: 'region',
|
||||||
|
COMPARE: 'compare',
|
||||||
|
};
|
||||||
|
|
||||||
export function useDashboard() {
|
export function useDashboard() {
|
||||||
const [isLive, setIsLive] = useState(true);
|
const [isLive, setIsLive] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
@@ -12,6 +20,20 @@ export function useDashboard() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// New states for views
|
||||||
|
const [viewMode, setViewMode] = useState(VIEW_MODES.GENERAL);
|
||||||
|
const [allLines, setAllLines] = useState([]);
|
||||||
|
const [allRegions, setAllRegions] = useState([]);
|
||||||
|
const [selectedLine, setSelectedLine] = useState(null);
|
||||||
|
const [selectedRegion, setSelectedRegion] = useState(null);
|
||||||
|
const [lineDetails, setLineDetails] = useState(null);
|
||||||
|
const [regionDetails, setRegionDetails] = useState(null);
|
||||||
|
const [compareData, setCompareData] = useState(null);
|
||||||
|
const [compareRanges, setCompareRanges] = useState({
|
||||||
|
range1: { start: null, end: null },
|
||||||
|
range2: { start: null, end: null },
|
||||||
|
});
|
||||||
|
|
||||||
const refreshIntervalRef = useRef(null);
|
const refreshIntervalRef = useRef(null);
|
||||||
|
|
||||||
// Fetch available data range
|
// Fetch available data range
|
||||||
@@ -94,6 +116,123 @@ export function useDashboard() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch all lines
|
||||||
|
const fetchAllLines = useCallback(async (hours = 24) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/dashboard/lines?hours=${hours}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch lines');
|
||||||
|
const data = await response.json();
|
||||||
|
setAllLines(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching all lines:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch all regions
|
||||||
|
const fetchAllRegions = useCallback(async (hours = 24) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/dashboard/regions?hours=${hours}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch regions');
|
||||||
|
const data = await response.json();
|
||||||
|
setAllRegions(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching all regions:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch line details
|
||||||
|
const fetchLineDetails = useCallback(async (lineCode, nucleo, hours = 24) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const params = new URLSearchParams({ hours: hours.toString() });
|
||||||
|
if (nucleo) params.append('nucleo', nucleo);
|
||||||
|
const response = await fetch(`${API_URL}/dashboard/line/${lineCode}?${params}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch line details');
|
||||||
|
const data = await response.json();
|
||||||
|
setLineDetails(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching line details:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch region details
|
||||||
|
const fetchRegionDetails = useCallback(async (nucleo, hours = 24) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await fetch(`${API_URL}/dashboard/region/${nucleo}?hours=${hours}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch region details');
|
||||||
|
const data = await response.json();
|
||||||
|
setRegionDetails(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching region details:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch comparison data
|
||||||
|
const fetchCompareData = useCallback(async (range1, range2, lineCode = null, nucleo = null) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start1: range1.start.toISOString(),
|
||||||
|
end1: range1.end.toISOString(),
|
||||||
|
start2: range2.start.toISOString(),
|
||||||
|
end2: range2.end.toISOString(),
|
||||||
|
});
|
||||||
|
if (lineCode) params.append('lineCode', lineCode);
|
||||||
|
if (nucleo) params.append('nucleo', nucleo);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/dashboard/compare?${params}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch comparison data');
|
||||||
|
const data = await response.json();
|
||||||
|
setCompareData(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching comparison data:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Select a line to view
|
||||||
|
const selectLine = useCallback((lineCode, nucleo = null) => {
|
||||||
|
setSelectedLine({ lineCode, nucleo });
|
||||||
|
setViewMode(VIEW_MODES.LINE);
|
||||||
|
fetchLineDetails(lineCode, nucleo);
|
||||||
|
}, [fetchLineDetails]);
|
||||||
|
|
||||||
|
// Select a region to view
|
||||||
|
const selectRegion = useCallback((nucleo) => {
|
||||||
|
setSelectedRegion(nucleo);
|
||||||
|
setViewMode(VIEW_MODES.REGION);
|
||||||
|
fetchRegionDetails(nucleo);
|
||||||
|
}, [fetchRegionDetails]);
|
||||||
|
|
||||||
|
// Start comparison mode
|
||||||
|
const startCompare = useCallback((range1, range2, lineCode = null, nucleo = null) => {
|
||||||
|
setCompareRanges({ range1, range2 });
|
||||||
|
setViewMode(VIEW_MODES.COMPARE);
|
||||||
|
fetchCompareData(range1, range2, lineCode, nucleo);
|
||||||
|
}, [fetchCompareData]);
|
||||||
|
|
||||||
|
// Go back to general view
|
||||||
|
const goToGeneral = useCallback(() => {
|
||||||
|
setViewMode(VIEW_MODES.GENERAL);
|
||||||
|
setSelectedLine(null);
|
||||||
|
setSelectedRegion(null);
|
||||||
|
setLineDetails(null);
|
||||||
|
setRegionDetails(null);
|
||||||
|
setCompareData(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Seek to specific time
|
// Seek to specific time
|
||||||
const seekTo = useCallback((timestamp) => {
|
const seekTo = useCallback((timestamp) => {
|
||||||
setIsLive(false);
|
setIsLive(false);
|
||||||
@@ -137,11 +276,13 @@ export function useDashboard() {
|
|||||||
const start = new Date(now.getTime() - 3600000);
|
const start = new Date(now.getTime() - 3600000);
|
||||||
await fetchTimeline(start, now, 5);
|
await fetchTimeline(start, now, 5);
|
||||||
await fetchLinesRanking(now, 24);
|
await fetchLinesRanking(now, 24);
|
||||||
|
await fetchAllLines(24);
|
||||||
|
await fetchAllRegions(24);
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
init();
|
init();
|
||||||
}, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking]);
|
}, [fetchAvailableRange, fetchCurrentStats, fetchTimeline, fetchLinesRanking, fetchAllLines, fetchAllRegions]);
|
||||||
|
|
||||||
// Auto-refresh when live
|
// Auto-refresh when live
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,6 +308,7 @@ export function useDashboard() {
|
|||||||
}, [isLive, fetchCurrentStats, fetchTimeline]);
|
}, [isLive, fetchCurrentStats, fetchTimeline]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Original state
|
||||||
isLive,
|
isLive,
|
||||||
currentTime,
|
currentTime,
|
||||||
stats,
|
stats,
|
||||||
@@ -179,5 +321,23 @@ export function useDashboard() {
|
|||||||
goLive,
|
goLive,
|
||||||
skip,
|
skip,
|
||||||
setIsLive,
|
setIsLive,
|
||||||
|
// New view states
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
allLines,
|
||||||
|
allRegions,
|
||||||
|
selectedLine,
|
||||||
|
selectedRegion,
|
||||||
|
lineDetails,
|
||||||
|
regionDetails,
|
||||||
|
compareData,
|
||||||
|
compareRanges,
|
||||||
|
// New view actions
|
||||||
|
selectLine,
|
||||||
|
selectRegion,
|
||||||
|
startCompare,
|
||||||
|
goToGeneral,
|
||||||
|
fetchAllLines,
|
||||||
|
fetchAllRegions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { calculateBearing, calculateDistance, MIN_DISTANCE_FOR_BEARING } from '.
|
|||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-calculate bearings for historical data based on consecutive positions
|
* Pre-calculate bearings for historical data based on the last different position
|
||||||
|
* Searches backwards to find a position with significant distance for accurate bearing
|
||||||
* @param {Array} positions - Array of positions sorted by timestamp ASC
|
* @param {Array} positions - Array of positions sorted by timestamp ASC
|
||||||
* @returns {Array} - Positions with calculated bearings
|
* @returns {Array} - Positions with calculated bearings
|
||||||
*/
|
*/
|
||||||
@@ -26,19 +27,43 @@ function calculateHistoricalBearings(positions) {
|
|||||||
// Sort by timestamp (should already be sorted, but ensure)
|
// Sort by timestamp (should already be sorted, but ensure)
|
||||||
trainPos.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
trainPos.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||||
|
|
||||||
|
let lastSignificantPos = null; // Track last position with significant movement
|
||||||
|
let lastBearing = null; // Track last calculated bearing
|
||||||
|
|
||||||
for (let i = 0; i < trainPos.length; i++) {
|
for (let i = 0; i < trainPos.length; i++) {
|
||||||
const current = trainPos[i];
|
const current = trainPos[i];
|
||||||
let bearing = current.bearing; // Use existing bearing if available
|
let bearing = current.bearing; // Use existing bearing if available
|
||||||
|
|
||||||
if (bearing === null || bearing === undefined) {
|
if (bearing === null || bearing === undefined) {
|
||||||
// Calculate bearing from previous position
|
// Search backwards for the last position with significant distance
|
||||||
if (i > 0) {
|
if (lastSignificantPos) {
|
||||||
const prev = trainPos[i - 1];
|
const distance = calculateDistance(
|
||||||
const distance = calculateDistance(prev.latitude, prev.longitude, current.latitude, current.longitude);
|
lastSignificantPos.latitude,
|
||||||
|
lastSignificantPos.longitude,
|
||||||
|
current.latitude,
|
||||||
|
current.longitude
|
||||||
|
);
|
||||||
if (distance >= MIN_DISTANCE_FOR_BEARING) {
|
if (distance >= MIN_DISTANCE_FOR_BEARING) {
|
||||||
bearing = calculateBearing(prev.latitude, prev.longitude, current.latitude, current.longitude);
|
bearing = calculateBearing(
|
||||||
|
lastSignificantPos.latitude,
|
||||||
|
lastSignificantPos.longitude,
|
||||||
|
current.latitude,
|
||||||
|
current.longitude
|
||||||
|
);
|
||||||
|
lastSignificantPos = current;
|
||||||
|
lastBearing = bearing;
|
||||||
|
} else {
|
||||||
|
// No significant movement, keep the last known bearing
|
||||||
|
bearing = lastBearing;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// First position for this train
|
||||||
|
lastSignificantPos = current;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Has bearing from API, update tracking
|
||||||
|
lastSignificantPos = current;
|
||||||
|
lastBearing = bearing;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
|
|||||||
@@ -6,28 +6,31 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
|||||||
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
|
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
// Calculate bearing for trains based on previous positions
|
// Calculate bearing for trains based on previous positions
|
||||||
|
// Only updates the stored position when there's significant movement
|
||||||
function addCalculatedBearings(newTrains, previousPositions) {
|
function addCalculatedBearings(newTrains, previousPositions) {
|
||||||
return newTrains.map(train => {
|
return newTrains.map(train => {
|
||||||
// If train already has bearing from API, use it
|
// If train already has bearing from API, use it
|
||||||
if (train.bearing !== null && train.bearing !== undefined) {
|
if (train.bearing !== null && train.bearing !== undefined) {
|
||||||
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
|
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude, bearing: train.bearing });
|
||||||
return train;
|
return train;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevPos = previousPositions.get(train.train_id);
|
const prevPos = previousPositions.get(train.train_id);
|
||||||
let calculatedBearing = null;
|
let calculatedBearing = prevPos?.bearing ?? null;
|
||||||
|
|
||||||
if (prevPos) {
|
if (prevPos) {
|
||||||
const distance = calculateDistance(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
|
const distance = calculateDistance(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
|
||||||
// Only calculate bearing if the train moved enough
|
// Only calculate bearing and update position if the train moved enough
|
||||||
if (distance >= MIN_DISTANCE_FOR_BEARING) {
|
if (distance >= MIN_DISTANCE_FOR_BEARING) {
|
||||||
calculatedBearing = calculateBearing(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
|
calculatedBearing = calculateBearing(prevPos.lat, prevPos.lon, train.latitude, train.longitude);
|
||||||
|
// Only update stored position when there's significant movement
|
||||||
|
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude, bearing: calculatedBearing });
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// First time seeing this train, store position without bearing
|
||||||
|
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude, bearing: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update previous position
|
|
||||||
previousPositions.set(train.train_id, { lat: train.latitude, lon: train.longitude });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...train,
|
...train,
|
||||||
bearing: calculatedBearing,
|
bearing: calculatedBearing,
|
||||||
|
|||||||
@@ -835,6 +835,522 @@ body {
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View Tabs */
|
||||||
|
.view-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
background: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f6fa;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab.active {
|
||||||
|
background: #3498DB;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selector Container */
|
||||||
|
.selector-container {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-button:hover {
|
||||||
|
border-color: #3498DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-option:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-option .line-code,
|
||||||
|
.selector-option .region-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3498DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-option .line-nucleo,
|
||||||
|
.selector-option .region-lines {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-option .line-trains,
|
||||||
|
.selector-option .region-trains {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection View */
|
||||||
|
.selection-view {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 25px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-view h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card.clickable:hover {
|
||||||
|
border-color: #3498DB;
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card-header .line-code,
|
||||||
|
.selection-card-header .region-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3498DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card-header .line-nucleo {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-card-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail View */
|
||||||
|
.detail-view {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #666;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f6fa;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stations List */
|
||||||
|
.stations-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-rank {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-code {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-trains {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-delay {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Region Lines List */
|
||||||
|
.region-lines-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-line-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-line-item.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-line-item.clickable:hover {
|
||||||
|
background: #e8f4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-line-item .line-code {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3498DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-line-item .line-trains {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-line-item .line-punctuality {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compare Setup */
|
||||||
|
.compare-setup {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-picker {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-picker label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-inputs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-inputs input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-inputs input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-inputs span {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-setup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3498DB;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2980B9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f5f6fa;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compare View */
|
||||||
|
.compare-ranges-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-range-label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-range-label strong {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-card.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-values {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-value {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-number {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-value.positive {
|
||||||
|
color: #27AE60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-value.negative {
|
||||||
|
color: #E74C3C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-neutral {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct-change {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-punctuality {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-punctuality-side h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable rows */
|
||||||
|
.lines-table-row.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines-table-row.clickable:hover {
|
||||||
|
background: #e8f4fc;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -854,4 +1370,21 @@ body {
|
|||||||
.timeline-container {
|
.timeline-container {
|
||||||
right: 20px;
|
right: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab {
|
||||||
|
flex: 1 1 calc(50% - 4px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-punctuality {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user