Files
ferrovia-infogare/src/pages/afficheurs/eva/EVAArrivees.js
T
mathis 72de543dc6 RELEASE V5.0.0 : Voici les modifications apportées :
-> IMPLÉMENTATION DONNÉES GTFS / GTFS-RT / SIRI LTE SNCF
-> RESTRUCTURATION DE L'APP.

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

-> REFONTE DU DESIGN DES AFFICHEURS CLASSIQUES
-> AMÉLIORATION DES
2026-08-30 01:58:16 +02:00

1441 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useCallback, useRef } from 'react';
import './EVAArrivees.css';
import supabase from '../../../lib/supabase';
import { fetchAllRows } from '../../../lib/fetchAllRows';
import MarqueeText from '../../../components/MarqueeText';
import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
import useAutoAnnonce from '../../../hooks/useAutoAnnonce';
import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi';
// Helper utilitaire : format HH:MM avec deux points (ex: 22:39)
const formatTimeWithColon = (v) => {
if (v == null) return '';
const tryDate = new Date(v);
if (!isNaN(tryDate.getTime())) {
const hh = String(tryDate.getHours() % 24).padStart(2, '0');
const mm = String(tryDate.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
if (typeof v === 'string') {
const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);
if (m) {
const h = parseInt(m[1], 10) % 24;
return `${String(h).padStart(2, '0')}:${m[2]}`;
}
const m2 = v.match(/T(\d{2}):(\d{2})/);
if (m2) {
const h = parseInt(m2[1], 10) % 24;
return `${String(h).padStart(2, '0')}:${m2[2]}`;
}
const m3 = v.match(/(\d{1,2})h(\d{2})/i);
if (m3) {
const h = parseInt(m3[1], 10) % 24;
return `${String(h).padStart(2, '0')}:${m3[2]}`;
}
return v.slice(0, 5).replace('h', ':');
}
return String(v).slice(0, 5).replace('h', ':');
};
// Icône Passager avec valise à roulettes + Face avant de train (Logo En-tête EVA Arrivées)
const EVAArrivalsHeaderIcon = ({ size = '59px', color = '#00d68f', style = {} }) => (
<svg
viewBox="0 0 64 64"
width={size}
height={size}
fill="none"
style={{ flexShrink: 0, ...style }}
>
{/* Voyageur avec valise */}
<circle cx="16" cy="18" r="3.2" fill={color} />
{/* Corps et jambes */}
<path
d="M12 25h7l3 11-3.5 1.5-1.5-6-2 10-4.5-1.5 2.5-11-2-1.5z"
fill={color}
/>
{/* Bras tirant la valise */}
<path
d="M14 26l-7 14"
stroke={color}
strokeWidth="2.2"
strokeLinecap="round"
/>
{/* Valise à roulettes */}
<rect x="3.5" y="38" width="5.5" height="11" rx="1.5" fill={color} />
<circle cx="4.5" cy="50.5" r="1.2" fill={color} />
<circle cx="8" cy="50.5" r="1.2" fill={color} />
{/* Train de face */}
<path
d="M26 18c0-5 3.5-7.5 12-7.5s12 2.5 12 7.5v27c0 2.5-2 4.5-4.5 4.5h-15c-2.5 0-4.5-2-4.5-4.5V18z"
fill={color}
/>
{/* Pare-brise (découpé en bleu nuit) */}
<rect x="30" y="16" width="16" height="10" rx="2.5" fill="#000824" />
{/* Feux avant du train */}
<circle cx="31.5" cy="40.5" r="2.0" fill="#000824" />
<circle cx="44.5" cy="40.5" r="2.0" fill="#000824" />
{/* Grille / Ligne basse du train */}
<rect x="34" y="42" width="8" height="2" rx="1" fill="#000824" />
{/* Rails au sol */}
<line x1="2" y1="53" x2="62" y2="53" stroke={color} strokeWidth="2.8" strokeLinecap="round" />
<line x1="24" y1="57" x2="52" y2="57" stroke={color} strokeWidth="2" strokeLinecap="round" />
</svg>
);
// Icône de Bus / Autocar SNCF
const EVABusIcon = ({ size = '65px', color = '#ffffff', style = {} }) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill={color}
style={{ flexShrink: 0, ...style }}
>
<path d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z" />
</svg>
);
// Icône Information (Badge motif perturbation)
const EVAInfoIcon = ({ size = '26px', color = '#000c2f', style = {} }) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke={color}
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0, ...style }}
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" strokeWidth="3.2" />
</svg>
);
// Icône Information Grande (Bandeau Travaux inférieur)
const EVABigAlertIcon = ({ size = '63px', color = '#000c2f', style = {} }) => (
<svg
viewBox="0 0 48 48"
width={size}
height={size}
fill="none"
style={{ flexShrink: 0, ...style }}
>
{/* Bulle / Cercle avec pointe basse */}
<path
d="M24 6C13.5 6 5 14.5 5 25c0 4.5 1.6 8.7 4.3 12L7 42l6-2c3.1 1.9 6.8 3 11 3 10.5 0 19-8.5 19-19S34.5 6 24 6z"
stroke={color}
strokeWidth="3.2"
strokeLinejoin="round"
/>
{/* Point 'i' */}
<circle cx="24" cy="16" r="2.4" fill={color} />
{/* Barre 'i' */}
<path d="M22 22h4v12h-4" stroke={color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
{/* Ligne sous le bandeau */}
<path d="M4 44h40" stroke={color} strokeWidth="3" strokeLinecap="round" />
</svg>
);
// Icône Horloge pour les pastilles de statut
const EVAClockIcon = ({ size = '27px', color = 'currentColor', style = {} }) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke={color}
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0, ...style }}
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
// Icône Suppression (Croix)
const EVACrossIcon = ({ size = '27px', color = 'currentColor', style = {} }) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke={color}
strokeWidth="2.8"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0, ...style }}
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
// Défilement des gares de passage (Vias)
const EVAViasMarquee = ({ viaNames }) => {
const containerRef = useRef(null);
const textRef = useRef(null);
const [isOverflowing, setIsOverflowing] = useState(false);
const [duration, setDuration] = useState(6);
useEffect(() => {
const check = () => {
if (containerRef.current && textRef.current) {
const cWidth = containerRef.current.clientWidth;
const tWidth = textRef.current.scrollWidth;
if (tWidth > cWidth) {
setIsOverflowing(true);
setDuration(Math.max(2.0, tWidth / 240));
} else {
setIsOverflowing(false);
}
}
};
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, [viaNames]);
if (!viaNames || viaNames.length === 0) {
return (
<div className="eva-arr-vias-container">
<span className="eva-arr-vias-stop">Direct</span>
</div>
);
}
return (
<div className="eva-arr-vias-container">
<span className="eva-arr-vias-label">via</span>
<div className="eva-arr-vias-scroll-wrapper" ref={containerRef}>
<div
ref={textRef}
className={`eva-arr-vias-inner ${isOverflowing ? 'eva-arr-vias-inner--animating' : ''}`}
style={{
animationDuration: isOverflowing ? `${duration}s` : undefined
}}
>
{viaNames.map((v, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="eva-arr-vias-bullet">•</span>}
<span className="eva-arr-vias-stop">{v}</span>
</React.Fragment>
))}
</div>
</div>
</div>
);
};
// Helper de décodage des caractères, entités HTML et URL
const decodeText = (raw) => {
if (raw == null) return '';
let str = String(raw);
// 1. Décodage d'URL si présent (ex: %20, %C3%A9...)
if (str.includes('%')) {
try { str = decodeURIComponent(str); } catch (e) {}
}
// 2. Traitement des séquences d'échappement retour à la ligne et apostrophes
str = str.replace(/\\r\\n|\\r|\\n/g, ' ').replace(/\\'/g, "'").replace(/\\"/g, '"');
// 3. Décodage des entités HTML avec DOMParser dans le navigateur
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
try {
const doc = new DOMParser().parseFromString(str, 'text/html');
str = doc.body.textContent || doc.body.innerText || str;
} catch (e) {}
}
// 4. Décodage manuel de secours pour entités résiduelles
const htmlEntitiesMap = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&rsquo;': "’",
'&lsquo;': "‘",
'&apos;': "'",
'&laquo;': '«',
'&raquo;': '»',
'&Agrave;': 'À', '&Acirc;': 'Â', '&Auml;': 'Ä', '&Eacute;': 'É', '&Egrave;': 'È', '&Ecirc;': 'Ê', '&Euml;': 'Ë',
'&Icirc;': 'Î', '&Iuml;': 'Ï', '&Ocirc;': 'Ô', '&Ouml;': 'Ö', '&Ugrave;': 'Ù', '&Ucirc;': 'Û', '&Uuml;': 'Ü',
'&Ccedil;': 'Ç',
'&agrave;': 'à', '&acirc;': 'â', '&auml;': 'ä', '&eacute;': 'é', '&egrave;': 'è', '&ecirc;': 'ê', '&euml;': 'ë',
'&icirc;': 'î', '&iuml;': 'ï', '&ocirc;': 'ô', '&ouml;': 'ö', '&ugrave;': 'ù', '&ucirc;': 'û', '&uuml;': 'ü',
'&ccedil;': 'ç'
};
str = str.replace(/&[a-zA-Z0-9#x]+;/g, (m) => htmlEntitiesMap[m] || m);
// 5. Nettoyage des espaces consécutifs
return str.replace(/[ \t\r\n]+/g, ' ').trim();
};
// Composant pour le bandeau d'alerte travaux / perturbations avec scroll vertical progressif
const EVABottomAlert = ({ message }) => {
const containerRef = useRef(null);
const contentRef = useRef(null);
const [isOverflowing, setIsOverflowing] = useState(false);
const [overflowDistance, setOverflowDistance] = useState(0);
const [animState, setAnimState] = useState('initial_pause');
const decodedText = decodeText(message);
useEffect(() => {
const measure = () => {
if (containerRef.current && contentRef.current) {
const cHeight = containerRef.current.clientHeight;
const sHeight = contentRef.current.scrollHeight;
if (sHeight > cHeight + 2) {
setIsOverflowing(true);
setOverflowDistance(sHeight - cHeight);
} else {
setIsOverflowing(false);
setOverflowDistance(0);
}
}
};
measure();
const to = setTimeout(measure, 150);
window.addEventListener('resize', measure);
return () => {
clearTimeout(to);
window.removeEventListener('resize', measure);
};
}, [decodedText]);
useEffect(() => {
if (!isOverflowing || overflowDistance <= 0) {
setAnimState('initial_pause');
return;
}
let timer;
if (animState === 'initial_pause') {
// 5 secondes d'attente en haut avant de démarrer le défilement
timer = setTimeout(() => {
setAnimState('scrolling');
}, 5000);
} else if (animState === 'scrolling') {
// Défilement progressif vers le bas pendant 10 secondes
timer = setTimeout(() => {
setAnimState('end_pause');
}, 10000);
} else if (animState === 'end_pause') {
// 5 secondes d'attente en bas avant de recommencer
timer = setTimeout(() => {
setAnimState('resetting');
}, 5000);
} else if (animState === 'resetting') {
// Réinitialisation fluide et retour en haut
timer = setTimeout(() => {
setAnimState('initial_pause');
}, 300);
}
return () => clearTimeout(timer);
}, [animState, isOverflowing, overflowDistance]);
if (!decodedText) return null;
let contentStyle = {
transform: 'translateY(0)',
transition: 'none',
opacity: 1
};
if (isOverflowing && overflowDistance > 0) {
if (animState === 'initial_pause') {
contentStyle = {
transform: 'translateY(0)',
transition: 'none',
opacity: 1
};
} else if (animState === 'scrolling') {
contentStyle = {
transform: `translateY(-${overflowDistance}px)`,
transition: 'transform 10s linear',
opacity: 1
};
} else if (animState === 'end_pause') {
contentStyle = {
transform: `translateY(-${overflowDistance}px)`,
transition: 'none',
opacity: 1
};
} else if (animState === 'resetting') {
contentStyle = {
transform: 'translateY(0)',
transition: 'opacity 0.25s ease',
opacity: 0
};
}
}
return (
<footer className="eva-arr-bottom-alert">
<div className="eva-arr-bottom-alert-icon-wrapper">
<EVABigAlertIcon className="eva-arr-bottom-alert-icon" />
</div>
<div className="eva-arr-bottom-alert-container" ref={containerRef}>
<div className="eva-arr-bottom-alert-content" ref={contentRef} style={contentStyle}>
{decodedText}
</div>
</div>
</footer>
);
};
// Horloge temps réel EVA (HH:MM en blanc, SS en vert menthe encadrée)
function Clock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return (
<div className="eva-arr-clock-box" aria-hidden>
<span className="eva-arr-clock-main">{hh}:{mm}</span>
<span className="eva-arr-clock-seconds">{ss}</span>
</div>
);
}
export default function EVAArrivees() {
// Paramètres d'URL : ?gare=Nom-de-la-gare, ?gare_id=123
const params = new URLSearchParams(window.location.search);
const gareParam = params.get('gare');
const gareIdParam = params.get('gare_id') || params.get('gareId');
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;
// Support pour ?page=1, ?page=2 pour verrouiller l'affichage sur une page spécifique
const pageParam = params.get('page');
const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10))
? Math.max(0, parseInt(pageParam, 10) - 1)
: null;
// Mode debug via ?debug=1
const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true');
// eslint-disable-next-line no-unused-vars
const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]);
// État heure courante
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
// Données Supabase
const [horaires, setHoraires] = useState([]);
const [allHoraires, setAllHoraires] = useState([]);
const [perturbations, setPerturbations] = useState([]);
// eslint-disable-next-line no-unused-vars
const [stationType, setStationType] = useState(null);
const [travaux, setTravaux] = useState([]);
const [travauxSubstitutions, setTravauxSubstitutions] = useState([]);
const [, setLoading] = useState(false);
const [, setError] = useState(null);
const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null);
useAnnonceSonoreRealtime(resolvedGareId, 'EVAArrivees');
// Récupération type de gare
useEffect(() => {
let mounted = true;
const source = getSourceFromPath(window.location.pathname);
const fetchStationType = async () => {
if (source === 'sncf') {
if (mounted) {
setStationType('ville');
setResolvedGareId(null);
}
return;
}
try {
let fetchedType = null;
let fetchedId = gareIdParam || null;
if (gareIdParam) {
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1);
if (!gerr && gdata && gdata.length) {
fetchedType = gdata[0] && gdata[0].type;
fetchedId = gdata[0] && gdata[0].id;
}
} else if (gareDisplay) {
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle);
if (!gerr && gdata && gdata.length) {
const exactMatch = gdata.find(g => {
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
return n1 === n2;
});
const chosen = exactMatch || gdata[0];
fetchedType = chosen ? chosen.type : null;
fetchedId = chosen ? chosen.id : null;
}
}
if (mounted) {
setStationType(fetchedType ? String(fetchedType).toLowerCase() : null);
setResolvedGareId(fetchedId);
}
} catch (e) { /* ignore */ }
};
fetchStationType();
return () => { mounted = false; };
}, [gareIdParam, gareDisplay]);
// Helpers pour perturbations
const normalizeName = useCallback((s) => {
if (!s) return '';
try {
const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();
return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');
} catch (e) {
return String(s).toLowerCase().trim();
}
}, []);
const safeParseArrets = useCallback((p) => {
const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations);
if (!raw) return [];
if (Array.isArray(raw)) return raw;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed.arrets)) return parsed.arrets;
return parsed;
}
return [];
} catch (e) { return []; }
}, []);
const getStops = useCallback((h) => {
if (!h) return [];
if (Array.isArray(h.stops) && h.stops.length) return h.stops;
if (Array.isArray(h.gares_desservies) && h.gares_desservies.length) return h.gares_desservies;
if (Array.isArray(h.garesDesservies) && h.garesDesservies.length) return h.garesDesservies;
const candidates = ['stops', 'gares_desservies', 'garesDesservies', 'gares_desservies_json'];
for (const key of candidates) {
const raw = h[key];
if (!raw) continue;
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') {
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }
}
}
return [];
}, []);
const parsePerturbArretsObject = useCallback((p) => {
const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations);
if (!raw) return { arrets: [], jours: null };
if (typeof raw === 'object') {
if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };
if (Array.isArray(raw)) return { arrets: raw, jours: null };
return { arrets: [], jours: null };
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };
}
return { arrets: [], jours: null };
} catch (e) {
return { arrets: [], jours: null };
}
}, []);
const getPerturbationJours = useCallback((p) => {
if (!p) return null;
const candidate = p.jours_perturbation || p.jours || null;
const result = new Set();
const pushDate = (d) => {
if (!d) return;
try {
const dd = new Date(d);
if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0, 10));
} catch (e) {}
};
if (candidate) {
if (Array.isArray(candidate)) {
candidate.forEach(item => pushDate(item.date || item));
} else if (typeof candidate === 'string') {
try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); }
catch (e) { pushDate(candidate); }
} else if (typeof candidate === 'object') {
if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it));
else pushDate(candidate.date || null);
}
}
try {
const parsedObj = parsePerturbArretsObject(p);
const jp = parsedObj.jours;
if (jp) {
if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it));
else if (typeof jp === 'string') {
try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); }
} else if (typeof jp === 'object') pushDate(jp.date || null);
}
} catch (e) {}
return result.size ? result : null;
}, [parsePerturbArretsObject]);
const parseDelayMinutesFromArret = useCallback((arret) => {
if (!arret) return null;
const keys = ['retard_minutes', 'retard', 'delay_minutes', 'delay', 'retard_min'];
for (const k of keys) {
if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') {
const n = Number(arret[k]);
if (!isNaN(n)) return Math.round(n);
}
}
return null;
}, []);
const buildPerturbMap = useCallback((list = perturbations, horairesList = allHoraires) => {
const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };
(list || []).forEach(p => {
const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;
if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }
const number = p.number || p.train_number || p.train || p.numero || p.numero_train;
if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }
const arrets = safeParseArrets(p);
if (Array.isArray(arrets) && arrets.length) {
arrets.forEach(a => {
const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;
const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }
if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }
});
}
if ((horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }
});
if (Array.isArray(horairesList) && horairesList.length) {
try {
const byH = Object.keys(idx.byHoraire || {});
if (byH && byH.length) {
for (const hid of byH) {
const h = horairesList.find(x => String(x.id || x.horaire_id || x.horaireId || x.numero) === String(hid));
if (h) {
const num = h.numero_train || h.number || h.numero || null;
if (num != null) {
const numKey = String(num);
idx.byNumber[numKey] = idx.byNumber[numKey] || [];
(idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p));
}
}
}
}
} catch (e) {}
}
return idx;
}, [perturbations, allHoraires, safeParseArrets, normalizeName]);
const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => {
const map = idx || buildPerturbMap();
const allPerturbs = [];
const hId = horaire && (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);
const number = horaire && (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);
if (map) {
const addedIds = new Set();
const addP = (p) => {
if (p && !addedIds.has(p.id)) {
addedIds.add(p.id);
allPerturbs.push(p);
}
};
if (hId != null && map.byHoraire && map.byHoraire[String(hId)]) {
map.byHoraire[String(hId)].forEach(addP);
}
if (number != null && map.byNumber && map.byNumber[String(number)]) {
map.byNumber[String(number)].forEach(addP);
}
if (stationId != null && map.byStopId && map.byStopId[String(stationId)]) {
map.byStopId[String(stationId)].forEach(x => addP(x.perturbation));
}
if (stationName && map.byStopName) {
const key = normalizeName(stationName);
if (map.byStopName[key]) map.byStopName[key].forEach(x => addP(x.perturbation));
for (const existingKey of Object.keys(map.byStopName)) {
if (existingKey.includes(key) || key.includes(existingKey)) {
map.byStopName[existingKey].forEach(x => addP(x.perturbation));
}
}
}
const trainStops = getStops(horaire);
if (Array.isArray(trainStops)) {
trainStops.forEach(s => {
const sId = s.gare_id || s.gareId || s.id || s.code;
const sName = s.gare_nom || s.nom || s.name || s.stop_name;
if (sId != null && map.byStopId && map.byStopId[String(sId)]) {
map.byStopId[String(sId)].forEach(x => addP(x.perturbation));
}
if (sName && map.byStopName) {
const skey = normalizeName(sName);
if (map.byStopName[skey]) map.byStopName[skey].forEach(x => addP(x.perturbation));
}
});
}
if (map.global) {
map.global.forEach(addP);
}
}
const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); };
let horaireYmd = toYMD(horaire && (horaire._date || horaire.date || horaire.date_service || horaire.service_date));
if (!horaireYmd && serviceDate) horaireYmd = serviceDate;
let maxDelay = null;
let isSuppressed = false;
let causeDetails = '';
for (const p of allPerturbs) {
try {
if (horaireYmd) {
const pDate = toYMD(p.date || p.date_service);
const db = toYMD(p.date_debut || p.start_date);
const df = toYMD(p.date_fin || p.end_date);
if (pDate && pDate !== horaireYmd) continue;
if (db && df && !(horaireYmd >= db && horaireYmd <= df)) continue;
}
} catch (e) {}
try {
const joursSet = getPerturbationJours(p);
if (joursSet && horaireYmd && !joursSet.has(horaireYmd)) continue;
} catch (e) {}
const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;
const numberRef = p.number || p.train_number || p.numero || p.numero_train;
const appliesByRef = ref && (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));
const appliesByNumber = numberRef && (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));
const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };
const arrets = parsedObj.arrets || [];
const trainStops = getStops(horaire);
let trainStopMatch = false;
const matchesTarget = (a) => {
if (!stationName && stationId == null) return false;
const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;
const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
if (stationId != null && String(aId) === String(stationId)) return true;
if (stationName && aName) {
const na = normalizeName(aName);
const ns = normalizeName(stationName);
return na === ns;
}
return false;
};
for (const a of arrets) {
const aId = a.gare_id || a.gareId || a.id || a.uic || a.code;
const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
const na = normalizeName(aName || '');
const isTarget = matchesTarget(a);
let isTrainStop = false;
if (trainStops && Array.isArray(trainStops)) {
isTrainStop = trainStops.some(s => {
const sId = s.gare_id || s.gareId || s.id || s.code;
const sName = s.gare_nom || s.nom || s.name || s.stop_name;
if (sId != null && aId != null && String(sId) === String(aId)) return true;
if (sName && aName) {
const ns = normalizeName(sName);
return ns === na;
}
return false;
});
}
const originName = normalizeName(horaire.gare_depart_nom || horaire.gare_depart || '');
const destName = normalizeName(horaire.gare_arrivee_nom || horaire.gare_arrivee || '');
if (originName === na || destName === na) isTrainStop = true;
if (isTrainStop) trainStopMatch = true;
if (isTarget) {
const t = (a.type_perturbation || a.type || '').toString().toLowerCase();
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) {
isSuppressed = true;
}
}
}
const isGlobal = (arrets.length === 0) && !ref && !numberRef;
const applies = appliesByRef || appliesByNumber || trainStopMatch || isGlobal;
if (applies) {
const t = (p.type_perturbation || p.type || '').toString().toLowerCase();
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) {
isSuppressed = true;
}
for (const a of arrets) {
const tType = (a.type_perturbation || a.type || '').toString().toLowerCase();
if (tType.includes('retard') || tType.includes('delay') || /retard/.test(tType)) {
const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;
if (maxDelay == null || d > maxDelay) maxDelay = d;
}
}
if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {
const d = parseDelayMinutesFromArret(p) || 0;
if (maxDelay == null || d > maxDelay) maxDelay = d;
}
// Extraction du motif / cause de la perturbation
const cause = p.cause_details || p.motif || p.cause || p.description || p.information || p.details || '';
if (cause) {
causeDetails = cause;
}
}
}
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails };
return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => {
const map = buildPerturbMap(perturbList || []);
const targetStationId = displayStationId || gareIdParam || null;
const targetStationName = displayStationName || gareDisplay || null;
return (list || []).map(h => {
const horaire = { ...h };
const stops = getStops(horaire);
const stopsStatus = [];
for (let i = 0; i < stops.length; i++) {
const s = stops[i] || {};
const stationId = s.gare_id || s.gareId || s.id || s.station_id || s.stationId || s.uic || s.code;
const stationName = s.gare_nom || s.nom || s.name || s.station_name || s.station_nom;
const st = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate);
stopsStatus.push({ stationId, stationName, status: st });
}
const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate);
let displayTime = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || '');
horaire._stopsStatus = stopsStatus;
const stopForStation = (stops || []).find(s => {
const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';
const ns = normalizeName(nm || '');
const target = normalizeName(targetStationName || '');
return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target);
}) || null;
horaire._status = displayStatus;
horaire._displayTime = displayTime;
const platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || '';
horaire._displayPlatform = String(platformVal).trim();
return horaire;
});
}, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]);
// Récupération des données (SNCF GTFS ou Supabase) avec polling
useEffect(() => {
let mounted = true;
let inFlight = false;
const source = getSourceFromPath(window.location.pathname);
const fetchData = async () => {
if (!mounted || inFlight) return;
inFlight = true;
if (source === 'sncf') {
setLoading(true);
setError(null);
try {
const { data: sncfData, error: sncfErr } = await getSncfTimetable({
gareName: gareDisplay || gareParam,
mode: 'arrivees'
});
if (!mounted) { inFlight = false; return; }
if (sncfErr) {
setError(sncfErr);
} else {
const formatted = (sncfData || []).map(t => {
const timeStr = t.heure_arrivee || t.heure_depart || t.time || '';
return {
...t,
_displayTime: formatTimeWithColon(timeStr),
_displayPlatform: t.quai || t.voie || '',
_arrHM: t._arrHM || 0,
_status: t._status || {
label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"),
detail: '',
delayMinutes: t.retard_minutes || null,
causeDetails: t.cause_retard || null
}
};
});
setHoraires(formatted.slice(0, 10));
setAllHoraires(formatted);
}
} catch (e) {
if (mounted) setError(e.message || String(e));
} finally {
if (mounted) setLoading(false);
inFlight = false;
}
return;
}
if (!supabase) {
setLoading(false);
inFlight = false;
return;
}
setLoading(true);
setError(null);
try {
const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }, { data: travauxSubstData }] = await Promise.all([
fetchAllRows(supabase.from('horaires').select('*')),
supabase.from('perturbations').select('*'),
supabase.from('travaux').select('*'),
supabase.from('travaux_substitutions').select('*')
]);
if (!mounted) { inFlight = false; return; }
if (hError || pError || tError) {
if (mounted) setError((hError || pError || tError).message || String(hError || pError || tError));
setLoading(false);
inFlight = false;
return;
}
let parsedHoraires = (horairesData || []).map(h => h || {});
parsedHoraires = parsedHoraires.map(h => {
try {
if (h && typeof h.gares_desservies === 'string') {
const parsed = JSON.parse(h.gares_desservies);
if (Array.isArray(parsed)) return { ...h, gares_desservies: parsed };
}
} catch (e) {}
return h;
});
if (mounted) setAllHoraires(parsedHoraires || []);
let displayHoraires = parsedHoraires.slice();
if (gareDisplay) {
const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));
displayHoraires = displayHoraires.filter(h => {
const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString());
if (departName === normalized) return false;
const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());
if (arriveeName === normalized) return true;
const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) {
for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString());
if (!stopName) continue;
const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
if (isOrigin) continue;
if (stopName === normalized) return true;
}
}
return false;
});
} else if (gareIdParam) {
const gid = String(gareIdParam);
displayHoraires = displayHoraires.filter(h => {
const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;
if (departId != null && String(departId) === gid) return false;
const destId = (h.gare_arrivee_id || h.gare_arrivee || null);
if (destId != null && String(destId) === gid) return true;
const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) {
for (const s of stopsList) {
const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
if (stopId == null) continue;
const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
if (isOrigin) continue;
if (String(stopId) === gid) return true;
}
}
return false;
});
}
const dateKeys = ['date_depart', 'date', 'jour', 'service_date', 'date_service', 'date_dep'];
const normalizeDate = (o) => {
for (const k of dateKeys) {
if (!o) continue;
const v = o[k];
if (!v) continue;
const d = new Date(v);
if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10);
}
return null;
};
const withDates = displayHoraires.map(h => ({ ...h, _date: normalizeDate(h) }));
const parseTimeHM = (s) => {
if (!s) return null;
if (typeof s === 'number') return s % 1440;
const m = String(s).match(/(\d{1,2})[:h](\d{2})/i);
if (m) {
const h = parseInt(m[1], 10) % 24;
return h * 60 + parseInt(m[2], 10);
}
const d = new Date(s);
if (!isNaN(d.getTime())) return d.getHours() * 60 + d.getMinutes();
return null;
};
const now = new Date();
const todayHM = now.getHours() * 60 + now.getMinutes();
const enrich = withDates.map(h => {
let raw = h.heure_arrivee || h.time || h.heure || '';
const stopsListForH = getStops(h);
if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) {
const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;
const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null;
const stop = byId || byName;
if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure;
}
const depHM = parseTimeHM(raw);
const displayTime = formatTimeWithColon(raw);
return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime };
});
const pad = (n) => String(n).padStart(2, '0');
const localDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const today = new Date();
const todayStr = localDateStr(today);
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => {
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const customDaysRaw = h.jours_personnalises;
if (!customDaysRaw) return false;
let customDays = [];
if (Array.isArray(customDaysRaw)) {
customDays = customDaysRaw;
} else if (typeof customDaysRaw === 'string') {
try { customDays = JSON.parse(customDaysRaw); } catch (e) { return false; }
}
if (Array.isArray(customDays)) {
const match = customDays.find(item => item.date === targetDateStr);
if (match) return Boolean(match.circule);
}
return false;
};
const travauxList = travauxData || [];
const travauxSubstList = travauxSubstData || [];
const parseDateOnly = (v) => {
if (!v) return null;
const d = new Date(v);
if (isNaN(d.getTime())) return null;
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
};
const parseJoursTravaux = (val) => {
if (val == null) return null;
if (Array.isArray(val)) return new Set(val.map(x => String(x).toLowerCase()));
const s = String(val || '');
const parts = s.split(/[,;\s]+/).map(p => p.trim()).filter(Boolean);
return parts.length ? new Set(parts.map(p => p.toLowerCase())) : null;
};
const weekdayShort = (i) => {
switch (i) { case 0: return 'dim'; case 1: return 'lun'; case 2: return 'mar'; case 3: return 'mer'; case 4: return 'jeu'; case 5: return 'ven'; case 6: return 'sam'; default: return null; }
};
const travauxApplies = (t, h, date) => {
if (!t) return false;
const db = parseDateOnly(t.date_debut || t.dateDebut || t.start_date);
const df = parseDateOnly(t.date_fin || t.dateFin || t.end_date);
if (!db || !df) return false;
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (d < db || d > df) return false;
const joursSet = parseJoursTravaux(t.jours_travaux || t.jours || t.days);
if (joursSet) {
const name = weekdayShort(d.getDay());
if (!name) return false;
let ok = false;
for (const v of joursSet) { if (v.includes(name) || name.includes(v)) { ok = true; break; } }
if (!ok) return false;
}
const startHM = parseTimeHM(t.heure_debut || t.heureDebut || t.start_time);
const endHM = parseTimeHM(t.heure_fin || t.heureFin || t.end_time);
if (startHM == null || endHM == null) return true;
if (typeof h._depHM === 'number') return h._depHM >= startHM && h._depHM <= endHM;
return false;
};
const isSubstitution = (h) => {
if (!h) return false;
return travauxSubstList.some(ts => String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero));
};
const enriched = enrich.map(h => {
let linked = false;
let appliesToday = false;
let appliesTomorrow = false;
for (const t of travauxList) {
const tLine = t.ligne_id || t.ligne || t.line_id || t.line || t.ligneId || t.lineId;
const hLine = h.ligne_id || h.ligne || h.line_id || h.line || h.ligneId || h.lineId;
if (typeof tLine !== 'undefined' && tLine !== null) {
if (typeof hLine === 'undefined' || hLine === null) continue;
if (String(tLine) !== String(hLine)) continue;
}
const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero;
if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true;
const substForThisTravail = travauxSubstList.some(ts => String(ts.travaux_id) === String(t.id) && (String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero)));
if (substForThisTravail) linked = true;
if (!appliesToday && travauxApplies(t, h, today)) appliesToday = true;
if (!appliesTomorrow && travauxApplies(t, h, tomorrow)) appliesTomorrow = true;
if (linked && appliesToday && appliesTomorrow) break;
}
return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow };
});
const todayCandidates = enriched.filter(h => {
if (isSubstitution(h)) {
return Boolean(h._linkedToTravaux && h._travauxAppliesToday);
}
if (h._date && h._date !== todayStr) return false;
if (!circulatesOn(h, today)) return false;
if (typeof h._depHM !== 'number') return false;
if (h._depHM < todayHM) return false;
if (h._travauxAppliesToday) return false;
return true;
});
const tomorrowCandidates = enriched.filter(h => {
if (isSubstitution(h)) {
return Boolean(h._linkedToTravaux && h._travauxAppliesTomorrow);
}
if (h._date && h._date !== tomorrowStr) return false;
if (!circulatesOn(h, tomorrow)) return false;
if (h._travauxAppliesTomorrow) return false;
return true;
});
let finalList;
let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999));
serviceDateStr = todayStr;
} else {
finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999));
serviceDateStr = tomorrowStr;
}
const filteredPerturb = (perturbData || []).filter(p => {
try {
const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); };
const pDate = toYMD(p.date || p.date_service);
const db = toYMD(p.date_debut || p.start_date);
const df = toYMD(p.date_fin || p.end_date);
if (pDate && pDate !== serviceDateStr) return false;
if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false;
try {
const joursSet = getPerturbationJours(p);
if (joursSet && !joursSet.has(serviceDateStr)) return false;
} catch (e) {}
return true;
} catch (e) {
return true;
}
});
const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr);
const withServiceDate = (annotated || []).map(a => ({ ...a, _serviceDate: serviceDateStr }));
setHoraires(withServiceDate || []);
setPerturbations(filteredPerturb || []);
setTravaux(travauxData || []);
setTravauxSubstitutions(travauxSubstData || []);
} catch (err) {
console.error('Erreur Supabase EVAArrivees', err);
if (mounted) setError(err.message || String(err));
} finally {
if (mounted) setLoading(false);
inFlight = false;
}
};
fetchData();
const intervalId = setInterval(fetchData, 15000);
return () => { mounted = false; clearInterval(intervalId); };
}, [gareDisplay, gareParam, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]);
// Alternance retard / texte toutes les 2s
const [alternateDelay, setAlternateDelay] = useState(false);
useEffect(() => {
const delayTimer = setInterval(() => {
setAlternateDelay(prev => !prev);
}, 2000);
return () => clearInterval(delayTimer);
}, []);
// Filtrage en temps réel : ne plus afficher les horaires dont l'heure est dépassée
const currentHM = currentTime.getHours() * 60 + currentTime.getMinutes();
const pad = (n) => String(n).padStart(2, '0');
const todayDateStr = `${currentTime.getFullYear()}-${pad(currentTime.getMonth() + 1)}-${pad(currentTime.getDate())}`;
const activeHoraires = (horaires || []).filter(h => {
const sDate = h._serviceDate || h._date;
if (!sDate || sDate === todayDateStr) {
if (typeof h._depHM === 'number') {
const delay = (h._status && typeof h._status.delayMinutes === 'number') ? h._status.delayMinutes : 0;
const effectiveHM = h._depHM + delay;
if (effectiveHM < currentHM) {
return false;
}
}
}
return true;
});
useAutoAnnonce(activeHoraires, currentTime, true);
// Pagination : 4 trains par page sur les écrans EVA
const ITEMS_PER_PAGE = 4;
const totalPages = Math.max(1, Math.ceil(activeHoraires.length / ITEMS_PER_PAGE));
const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0);
useEffect(() => {
if (lockedPageIndex !== null) {
setPageIndex(Math.min(lockedPageIndex, totalPages - 1));
return;
}
const pageInterval = setInterval(() => {
setPageIndex((prev) => {
if (totalPages <= 1) return 0;
return (prev + 1) % totalPages;
});
}, 10000); // 10 secondes par page
return () => clearInterval(pageInterval);
}, [lockedPageIndex, totalPages]);
const safePageIndex = Math.min(pageIndex, Math.max(0, totalPages - 1));
const displayedHoraires = activeHoraires.slice(safePageIndex * ITEMS_PER_PAGE, (safePageIndex + 1) * ITEMS_PER_PAGE);
// Recherche d'un message de travaux ou d'alerte pour le bandeau bas
const activeTravauxMessage = (() => {
// Chercher un message explicite dans travaux
if (Array.isArray(travaux) && travaux.length > 0) {
const activeT = travaux.find(t => {
const txt = t.message || t.description || t.texte || t.motif || t.details || t.titre || t.commentaire || '';
return Boolean(txt && txt.trim().length > 0);
});
if (activeT) {
return activeT.message || activeT.description || activeT.texte || activeT.motif || activeT.details || activeT.titre || activeT.commentaire;
}
}
// Chercher dans perturbations générales
if (Array.isArray(perturbations) && perturbations.length > 0) {
const activeP = perturbations.find(p => {
const txt = p.message || p.description || p.texte || p.cause_details || '';
return Boolean(txt && txt.trim().length > 0);
});
if (activeP) {
return activeP.message || activeP.description || activeP.texte || activeP.cause_details;
}
}
return null;
})();
return (
<div className="eva-arr-container">
{/* HEADER */}
<header className="eva-arr-header">
<div className="eva-arr-header-left">
<EVAArrivalsHeaderIcon className="eva-arr-header-icon" />
<div className="eva-arr-header-titles">
<span className="eva-arr-header-title-main">Arrivées</span>
<span className="eva-arr-header-slash">/</span>
<span className="eva-arr-header-title-sub">Arrivals From / Ankünfte Aus</span>
</div>
</div>
<div className="eva-arr-header-right">
<Clock />
</div>
</header>
{/* LISTE DES TRAINS (Cartes) */}
<ul className={`eva-arr-list ${activeTravauxMessage ? 'eva-arr-list--with-alert' : ''}`}>
{displayedHoraires.length === 0 ? (
<li className="eva-arr-row" key="empty">
<div style={{ color: '#000c2f', padding: '24px', fontSize: '30px', fontWeight: 'bold' }}>
Aucune arrivée prévue pour le moment.
</div>
</li>
) : (
displayedHoraires.map((h, idx) => {
const horaire = h || {};
const op = (horaire.type_train || horaire.op || horaire.type || 'TER').toUpperCase();
const number = horaire.numero_train || horaire.number || horaire.numero || '';
const rawPlatform = (
horaire._displayPlatform ||
horaire.quai_reel ||
horaire.quai ||
horaire.quai_prevu ||
horaire.platform ||
horaire.voie ||
horaire.track ||
''
).toString().trim();
const isCG = rawPlatform.toUpperCase() === 'CG' ||
rawPlatform.toUpperCase().includes('CG') ||
String(horaire.quai || '').toUpperCase().includes('CG') ||
String(horaire.quai_reel || '').toUpperCase().includes('CG') ||
String(horaire.quai_prevu || '').toUpperCase().includes('CG') ||
String(horaire.platform || '').toUpperCase().includes('CG');
const isCarTER = isCG ||
op.includes('CAR') ||
op.includes('AUTOCAR') ||
op.includes('BUS') ||
String(horaire.mode || '').toUpperCase().includes('BUS') ||
String(horaire.mode || '').toUpperCase().includes('CAR') ||
String(horaire.mode_transport || '').toUpperCase().includes('CAR') ||
String(horaire.mode_transport || '').toUpperCase().includes('BUS') ||
String(horaire.type_train || '').toUpperCase().includes('CAR') ||
String(horaire.type_train || '').toUpperCase().includes('BUS') ||
String(horaire.libelle_type_train || '').toUpperCase().includes('CAR') ||
String(horaire.libelle_type_train || '').toUpperCase().includes('BUS') ||
(travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero));
const time = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || '');
const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue';
const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null);
const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null);
const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));
const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé');
const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard');
let delayVal = '+10 min';
if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) {
delayVal = `+${status.delayMinutes} min`;
} else if (status?.delayMinutes) {
delayVal = `+${status.delayMinutes} min`;
} else if (status?.label && status.label.includes('+')) {
const m = status.label.match(/\+(\d+)\s*(?:min)?/);
if (m) delayVal = `+${m[1]} min`;
else delayVal = status.label;
}
let pillText = isSuppressed ? 'supprimé' : isDelayed ? (alternateDelay ? 'retardé' : delayVal) : 'à l\'heure';
const pillModifier = isSuppressed ? 'suppressed' : isDelayed ? 'delayed' : 'ontime';
// Motif de perturbation (ex: Embouteillages, Travaux, etc.)
const perturbationReason = decodeText(status?.causeDetails || (isDelayed && !status?.causeDetails ? 'Retard' : ''));
// Calcul des gares intermédiaires (Vias)
const stopsList = getStops(horaire);
const viaNames = [];
if (Array.isArray(stopsList)) {
for (const s of stopsList) {
const sName = s.gare_nom || s.nom || s.name || s.station_name || '';
if (!sName) continue;
const norm = normalizeName(sName);
if (norm === normalizeName(origin || '')) continue;
if (norm === normalizeName(stationName || '')) break;
viaNames.push(decodeText(sName));
}
}
const decodedOrigin = decodeText(origin);
return (
<li key={idx} className="eva-arr-row">
{/* COLONNE GAUCHE : HEURE & PASTILLE DE STATUT */}
<div className="eva-arr-col-time">
<div className="eva-arr-time-value">{time}</div>
<div className={`eva-arr-status-pill eva-arr-status-pill--${pillModifier}`}>
{isSuppressed ? (
<EVACrossIcon className="eva-arr-status-icon" color="#ffffff" />
) : (
<EVAClockIcon className="eva-arr-status-icon" color={isDelayed ? '#000c2f' : '#1a2b4c'} />
)}
<span className="eva-arr-status-text">{pillText}</span>
</div>
</div>
{/* SECTION CENTRALE : PROVENANCE & MOTIF PERTURBATION & VIAS */}
<div className="eva-arr-col-center">
<div className="eva-arr-origin-row">
<MarqueeText text={decodedOrigin} className="eva-arr-origin-name" style={{ flex: 'none' }} />
{perturbationReason && (
<div className="eva-arr-perturb-badge">
<EVAInfoIcon className="eva-arr-perturb-badge-icon" />
<span className="eva-arr-perturb-badge-text">{perturbationReason}</span>
</div>
)}
</div>
<EVAViasMarquee viaNames={viaNames} />
</div>
{/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */}
<div className="eva-arr-col-right">
{isCarTER ? (
<div className="eva-arr-bus-box">
<EVABusIcon className="eva-arr-bus-icon" />
</div>
) : (rawPlatform && !isCG) ? (
<div className="eva-arr-voie-box">
<span className="eva-arr-voie-label">Voie</span>
<span className="eva-arr-voie-val">{rawPlatform}</span>
</div>
) : null}
<div className="eva-arr-train-block">
<span className="eva-arr-train-op">{op}</span>
<span className="eva-arr-train-num">{number}</span>
</div>
</div>
</li>
);
})
)}
</ul>
{/* BANDEAU BAS : INFORMATION TRAVAUX / PERTURBATION AVEC SCROLL PROGRESSIF */}
{activeTravauxMessage && (
<EVABottomAlert message={activeTravauxMessage} />
)}
</div>
);
}