-
-
{displayTime}
-
- {isSuppressed ? (
-
-
-
-
- ) : isDelayed ? (
-
-
-
-
- ) : (
-
-
-
-
- )}
-
{displayStatusText}
+ {/* CORPS PRINCIPAL */}
+
+ {/* PANNEAU GAUCHE : CARTE D'INFORMATION DU TRAIN */}
+
+ {/* Ligne Heure de départ & Statut */}
+
+
{displayTime}
+
+ {renderPillIcon()}
+ {pillText}
- {destName}
-
- {trainType}
- {trainNum}
-
-
- {/* Right Pane (Timeline) */}
-
-
-
- {/* Symmetrical train outline under the stops list */}
-
-
-
-
-
-
-
-
-
-
-
+ })()
+ )}
+
+
+
+ {/* FOOTER : BOX VOIE EN BAS À DROITE (SANS REPÈRES DE QUAI NI COMPOSITION) */}
+
-
- {platformStr && (
-
- )}
+
);
};
diff --git a/src/pages/afficheurs/eva/EVAArrivees.css b/src/pages/afficheurs/eva/EVAArrivees.css
new file mode 100644
index 0000000..b981056
--- /dev/null
+++ b/src/pages/afficheurs/eva/EVAArrivees.css
@@ -0,0 +1,476 @@
+/* ==========================================================================
+ EVA ARRIVEES (AFFICHEUR VOYAGEURS EVA) - CSS
+ ========================================================================== */
+
+/* Container Principal */
+.eva-arr-container {
+ font-family: 'Avenir', 'Achemine', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important;
+ height: 100vh !important;
+ width: 100vw !important;
+ display: flex !important;
+ flex-direction: column !important;
+ background-color: #000824 !important; /* Bleu nuit très profond EVA */
+ overflow: hidden !important;
+ color: #ffffff !important;
+ margin: 0 !important;
+ padding: 1.2vh 1.8vw !important;
+ box-sizing: border-box !important;
+ user-select: none !important;
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+}
+
+/* ==========================================================================
+ HEADER (En-tête EVA)
+ ========================================================================== */
+.eva-arr-header {
+ height: 9.5vh !important;
+ min-height: 9.5vh !important;
+ max-height: 9.5vh !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ padding: 0 0.5vw !important;
+ margin-bottom: 1.0vh !important;
+ box-sizing: border-box !important;
+}
+
+.eva-arr-header-left {
+ display: flex !important;
+ align-items: center !important;
+ gap: 1.0vw !important;
+}
+
+.eva-arr-header-icon {
+ width: 5.5vh !important;
+ height: 5.5vh !important;
+ flex-shrink: 0 !important;
+ color: #00d68f !important;
+}
+
+.eva-arr-header-titles {
+ display: flex !important;
+ align-items: baseline !important;
+ gap: 0.6vw !important;
+ white-space: nowrap !important;
+}
+
+.eva-arr-header-title-main {
+ font-size: 5.2vh !important;
+ font-weight: 900 !important;
+ color: #ffffff !important;
+ letter-spacing: 0.04vw !important;
+ line-height: 1 !important;
+ text-transform: uppercase !important;
+}
+
+.eva-arr-header-slash {
+ font-size: 3.8vh !important;
+ font-weight: 700 !important;
+ color: #00d68f !important;
+ line-height: 1 !important;
+}
+
+.eva-arr-header-title-sub {
+ font-size: 3.2vh !important;
+ font-weight: 700 !important;
+ color: #00d68f !important;
+ letter-spacing: 0.02vw !important;
+ line-height: 1 !important;
+ text-transform: uppercase !important;
+}
+
+.eva-arr-header-right {
+ display: flex !important;
+ align-items: center !important;
+}
+
+/* Horloge EVA */
+.eva-arr-clock-box {
+ border: 2.2px solid #00d68f !important;
+ background-color: #000824 !important;
+ border-radius: 8px !important;
+ padding: 0.5vh 1.0vw !important;
+ display: flex !important;
+ align-items: baseline !important;
+ gap: 0.5vw !important;
+ box-shadow: 0 0 10px rgba(0, 214, 143, 0.15) !important;
+}
+
+.eva-arr-clock-main {
+ font-size: 4.8vh !important;
+ font-weight: 800 !important;
+ color: #ffffff !important;
+ line-height: 1 !important;
+ letter-spacing: 0.02vw !important;
+}
+
+.eva-arr-clock-seconds {
+ font-size: 3.8vh !important;
+ font-weight: 800 !important;
+ color: #00d68f !important;
+ line-height: 1 !important;
+}
+
+/* ==========================================================================
+ LISTE DES HORAIRES (Cartes)
+ ========================================================================== */
+.eva-arr-list {
+ flex: 1 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: flex-start !important;
+ gap: 1.2vh !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ list-style: none !important;
+ overflow: hidden !important;
+}
+
+.eva-arr-row {
+ background-color: #e9eef6 !important;
+ border-radius: 10px !important;
+ display: flex !important;
+ align-items: stretch !important;
+ height: calc((100% - 3.6vh) / 4) !important;
+ min-height: 12vh !important;
+ box-sizing: border-box !important;
+ overflow: hidden !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25) !important;
+}
+
+/* --------------------------------------------------------------------------
+ COLONNE GAUCHE : HEURE & STATUT
+ -------------------------------------------------------------------------- */
+.eva-arr-col-time {
+ width: 14.5vw !important;
+ min-width: 14.5vw !important;
+ max-width: 14.5vw !important;
+ background-color: #d7e1f0 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: center !important;
+ align-items: center !important;
+ padding: 0.8vh 0.6vw !important;
+ box-sizing: border-box !important;
+ border-top-left-radius: 10px !important;
+ border-bottom-left-radius: 10px !important;
+}
+
+.eva-arr-time-value {
+ font-size: 5.6vh !important;
+ font-weight: 900 !important;
+ color: #000c2f !important;
+ line-height: 1 !important;
+ margin-bottom: 0.6vh !important;
+ letter-spacing: -0.02vw !important;
+}
+
+/* Pastilles de statut */
+.eva-arr-status-pill {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ gap: 0.4vw !important;
+ padding: 0.3vh 0.9vw !important;
+ border-radius: 20px !important;
+ box-sizing: border-box !important;
+ height: 3.8vh !important;
+}
+
+.eva-arr-status-pill--ontime {
+ background-color: #d7e1f0 !important;
+ border: 1.8px solid #7d95b5 !important;
+ color: #1a2b4c !important;
+}
+
+.eva-arr-status-pill--delayed {
+ background-color: #ffa74f !important;
+ color: #000c2f !important;
+}
+
+.eva-arr-status-pill--suppressed {
+ background-color: #ff5c73 !important;
+ color: #ffffff !important;
+}
+
+.eva-arr-status-icon {
+ width: 2.5vh !important;
+ height: 2.5vh !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-arr-status-text {
+ font-size: 2.2vh !important;
+ font-weight: 700 !important;
+ line-height: 1 !important;
+ white-space: nowrap !important;
+}
+
+/* --------------------------------------------------------------------------
+ SECTION CENTRALE : PROVENANCE & GARES DESSERVIES (VIAS)
+ -------------------------------------------------------------------------- */
+.eva-arr-col-center {
+ flex: 1 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: center !important;
+ padding: 0.6vh 1.8vw !important;
+ box-sizing: border-box !important;
+ overflow: hidden !important;
+ min-width: 0 !important;
+}
+
+.eva-arr-origin-row {
+ display: flex !important;
+ align-items: center !important;
+ gap: 0.8vw !important;
+ overflow: hidden !important;
+ white-space: nowrap !important;
+ margin-bottom: 0.4vh !important;
+}
+
+.eva-arr-origin-name {
+ font-size: 5.6vh !important;
+ font-weight: 900 !important;
+ color: #000c2f !important;
+ line-height: 1.1 !important;
+ letter-spacing: -0.02vw !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+}
+
+/* Badge Motif de Perturbation (ex: Embouteillages) */
+.eva-arr-perturb-badge {
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: 0.4vw !important;
+ background-color: #ffa74f !important;
+ color: #000c2f !important;
+ padding: 0.4vh 0.8vw !important;
+ border-radius: 6px !important;
+ height: 3.4vh !important;
+ box-sizing: border-box !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-arr-perturb-badge-icon {
+ width: 2.4vh !important;
+ height: 2.4vh !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-arr-perturb-badge-text {
+ font-size: 2.2vh !important;
+ font-weight: 700 !important;
+ line-height: 1 !important;
+ white-space: nowrap !important;
+}
+
+/* Section Vias */
+.eva-arr-vias-container {
+ display: flex !important;
+ align-items: baseline !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ width: 100% !important;
+}
+
+.eva-arr-vias-label {
+ font-size: 3.5vh !important;
+ font-weight: 700 !important;
+ color: #00c284 !important; /* Vert menthe SNCF */
+ margin-right: 0.5vw !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-arr-vias-scroll-wrapper {
+ flex: 1 !important;
+ overflow: hidden !important;
+ white-space: nowrap !important;
+ position: relative !important;
+ display: flex !important;
+ align-items: baseline !important;
+}
+
+.eva-arr-vias-inner {
+ display: inline-flex !important;
+ align-items: baseline !important;
+ white-space: nowrap !important;
+}
+
+.eva-arr-vias-inner--animating {
+ animation-name: eva-arr-vias-marquee !important;
+ animation-timing-function: linear !important;
+ animation-iteration-count: infinite !important;
+ padding-left: 20% !important;
+}
+
+@keyframes eva-arr-vias-marquee {
+ 0% {
+ transform: translateX(0%);
+ }
+ 100% {
+ transform: translateX(-100%);
+ }
+}
+
+.eva-arr-vias-stop {
+ font-size: 3.5vh !important;
+ font-weight: 500 !important;
+ color: #2c3b57 !important;
+ line-height: 1 !important;
+}
+
+.eva-arr-vias-bullet {
+ color: #00c284 !important;
+ font-weight: 800 !important;
+ margin: 0 0.5vw !important;
+ font-size: 2.8vh !important;
+ display: inline-block !important;
+ vertical-align: middle !important;
+}
+
+/* --------------------------------------------------------------------------
+ SECTION DROITE : MODE DE TRANSPORT / VOIE & OPERATEUR / NUMERO
+ -------------------------------------------------------------------------- */
+.eva-arr-col-right {
+ display: flex !important;
+ align-items: center !important;
+ height: 100% !important;
+ flex-shrink: 0 !important;
+}
+
+/* Boîte Picto Autocar/Bus */
+.eva-arr-bus-box {
+ background-color: #000c2f !important;
+ border-radius: 8px !important;
+ width: 7.0vw !important;
+ height: 80% !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ margin-right: 0.8vw !important;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important;
+}
+
+.eva-arr-bus-icon {
+ width: 6.0vh !important;
+ height: 6.0vh !important;
+ color: #ffffff !important;
+}
+
+/* Boîte Voie (si applicable pour train) */
+.eva-arr-voie-box {
+ background-color: #000c2f !important;
+ border-radius: 8px !important;
+ width: 7.0vw !important;
+ height: 80% !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ margin-right: 0.8vw !important;
+ color: #ffffff !important;
+}
+
+.eva-arr-voie-label {
+ font-size: 1.8vh !important;
+ font-weight: 600 !important;
+ text-transform: uppercase !important;
+ opacity: 0.8 !important;
+}
+
+.eva-arr-voie-val {
+ font-size: 4.5vh !important;
+ font-weight: 900 !important;
+ line-height: 1 !important;
+}
+
+/* Bloc Opérateur & Numéro Vert Émeraude */
+.eva-arr-train-block {
+ background-color: #00d68f !important;
+ width: 10.0vw !important;
+ min-width: 10.0vw !important;
+ height: 100% !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ border-top-right-radius: 10px !important;
+ border-bottom-right-radius: 10px !important;
+ padding: 0 0.5vw !important;
+ box-sizing: border-box !important;
+}
+
+.eva-arr-train-op {
+ font-size: 2.8vh !important;
+ font-weight: 700 !important;
+ color: #000c2f !important;
+ line-height: 1 !important;
+ margin-bottom: 0.3vh !important;
+ text-transform: uppercase !important;
+}
+
+.eva-arr-train-num {
+ font-size: 4.2vh !important;
+ font-weight: 900 !important;
+ color: #000c2f !important;
+ line-height: 1 !important;
+ letter-spacing: 0.02vw !important;
+}
+
+/* ==========================================================================
+ BANDEAU TRAVAUX / ALERTES (Pied de page)
+ ========================================================================== */
+.eva-arr-bottom-alert {
+ background-color: #fca758 !important;
+ border-radius: 10px !important;
+ display: flex !important;
+ align-items: center !important;
+ padding: 0.8vh 1.8vw !important;
+ margin-top: 1.0vh !important;
+ gap: 1.4vw !important;
+ box-sizing: border-box !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
+ flex-shrink: 0 !important;
+ height: 10.8vh !important;
+ min-height: 10.8vh !important;
+ max-height: 10.8vh !important;
+}
+
+.eva-arr-bottom-alert-icon-wrapper {
+ flex-shrink: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+}
+
+.eva-arr-bottom-alert-icon {
+ width: 5.8vh !important;
+ height: 5.8vh !important;
+ color: #000c2f !important;
+}
+
+.eva-arr-bottom-alert-container {
+ flex: 1 !important;
+ overflow: hidden !important;
+ position: relative !important;
+ height: 9.0vh !important;
+ max-height: 9.0vh !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: flex-start !important;
+}
+
+.eva-arr-bottom-alert-content {
+ color: #000c2f !important;
+ font-size: 2.4vh !important;
+ font-weight: 600 !important;
+ line-height: 1.25 !important;
+ white-space: normal !important;
+ word-break: break-word !important;
+ will-change: transform, opacity !important;
+}
diff --git a/src/pages/afficheurs/eva/EVAArrivees.js b/src/pages/afficheurs/eva/EVAArrivees.js
new file mode 100644
index 0000000..43ce099
--- /dev/null
+++ b/src/pages/afficheurs/eva/EVAArrivees.js
@@ -0,0 +1,1355 @@
+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';
+
+// 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()).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) return `${String(m[1]).padStart(2, '0')}:${m[2]}`;
+ const m2 = v.match(/T(\d{2}):(\d{2})/);
+ if (m2) return `${m2[1]}:${m2[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 = '5.5vh', color = '#00d68f', style = {} }) => (
+
+ {/* Voyageur avec valise */}
+
+ {/* Corps et jambes */}
+
+ {/* Bras tirant la valise */}
+
+ {/* Valise à roulettes */}
+
+
+
+
+ {/* Train de face */}
+
+ {/* Pare-brise (découpé en bleu nuit) */}
+
+ {/* Feux avant du train */}
+
+
+ {/* Grille / Ligne basse du train */}
+
+ {/* Rails au sol */}
+
+
+
+);
+
+// Icône de Bus / Autocar SNCF
+const EVABusIcon = ({ size = '6.0vh', color = '#ffffff', style = {} }) => (
+
+
+
+);
+
+// Icône Information (Badge motif perturbation)
+const EVAInfoIcon = ({ size = '2.4vh', color = '#000c2f', style = {} }) => (
+
+
+
+
+
+);
+
+// Icône Information Grande (Bandeau Travaux inférieur)
+const EVABigAlertIcon = ({ size = '5.8vh', color = '#000c2f', style = {} }) => (
+
+ {/* Bulle / Cercle avec pointe basse */}
+
+ {/* Point 'i' */}
+
+ {/* Barre 'i' */}
+
+ {/* Ligne sous le bandeau */}
+
+
+);
+
+// Icône Horloge pour les pastilles de statut
+const EVAClockIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => (
+
+
+
+
+);
+
+// Icône Suppression (Croix)
+const EVACrossIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => (
+
+
+
+
+
+);
+
+// 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 (
+
+ Direct
+
+ );
+ }
+
+ return (
+
+
via
+
+
+ {viaNames.map((v, i) => (
+
+ {i > 0 && • }
+ {v}
+
+ ))}
+
+
+
+ );
+};
+
+// 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 = {
+ ' ': ' ',
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': "'",
+ '’': "’",
+ '‘': "‘",
+ ''': "'",
+ '«': '«',
+ '»': '»',
+ 'À': 'À', 'Â': 'Â', 'Ä': 'Ä', 'É': 'É', 'È': 'È', 'Ê': 'Ê', 'Ë': 'Ë',
+ 'Î': 'Î', 'Ï': 'Ï', 'Ô': 'Ô', 'Ö': 'Ö', 'Ù': 'Ù', 'Û': 'Û', 'Ü': 'Ü',
+ 'Ç': 'Ç',
+ 'à': 'à', 'â': 'â', 'ä': 'ä', 'é': 'é', 'è': 'è', 'ê': 'ê', 'ë': 'ë',
+ 'î': 'î', 'ï': 'ï', 'ô': 'ô', 'ö': 'ö', 'ù': 'ù', 'û': 'û', 'ü': 'ü',
+ 'ç': 'ç'
+ };
+ 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 (
+
+ );
+};
+
+// 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 (
+
+ {hh}:{mm}
+ {ss}
+
+ );
+}
+
+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');
+ useAutoAnnonce(horaires, currentTime, true);
+
+ // Récupération type de gare
+ useEffect(() => {
+ let mounted = true;
+ const fetchStationType = async () => {
+ 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 */ }
+ };
+ if (supabase) {
+ 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 Supabase avec polling
+ useEffect(() => {
+ let mounted = true;
+ let inFlight = false;
+
+ const fetchData = async () => {
+ if (!mounted || inFlight) return;
+ inFlight = true;
+ 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;
+ const m = String(s).match(/(\d{1,2}):(\d{2})/);
+ if (m) return parseInt(m[1], 10) * 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 - 1) 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);
+
+ setHoraires(annotated || []);
+ 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, 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);
+ }, []);
+
+ // Pagination : 4 trains par page sur les écrans EVA
+ const ITEMS_PER_PAGE = 4;
+ const totalPages = Math.max(1, Math.ceil(horaires.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 displayedHoraires = horaires.slice(pageIndex * ITEMS_PER_PAGE, (pageIndex + 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 (
+
+ {/* HEADER */}
+
+
+ {/* LISTE DES TRAINS (Cartes) */}
+
+
+ {/* BANDEAU BAS : INFORMATION TRAVAUX / PERTURBATION AVEC SCROLL PROGRESSIF */}
+ {activeTravauxMessage && (
+
+ )}
+
+ );
+}
diff --git a/src/pages/afficheurs/eva/EVADeparts.css b/src/pages/afficheurs/eva/EVADeparts.css
new file mode 100644
index 0000000..400e961
--- /dev/null
+++ b/src/pages/afficheurs/eva/EVADeparts.css
@@ -0,0 +1,476 @@
+/* ==========================================================================
+ EVA DEPARTS (AFFICHEUR VOYAGEURS EVA) - CSS
+ ========================================================================== */
+
+/* Container Principal */
+.eva-dep-container {
+ font-family: 'Avenir', 'Achemine', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important;
+ height: 100vh !important;
+ width: 100vw !important;
+ display: flex !important;
+ flex-direction: column !important;
+ background-color: #000824 !important; /* Bleu nuit très profond EVA */
+ overflow: hidden !important;
+ color: #ffffff !important;
+ margin: 0 !important;
+ padding: 1.2vh 1.8vw !important;
+ box-sizing: border-box !important;
+ user-select: none !important;
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+}
+
+/* ==========================================================================
+ HEADER (En-tête EVA Départs)
+ ========================================================================== */
+.eva-dep-header {
+ height: 9.5vh !important;
+ min-height: 9.5vh !important;
+ max-height: 9.5vh !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ padding: 0 0.5vw !important;
+ margin-bottom: 1.0vh !important;
+ box-sizing: border-box !important;
+}
+
+.eva-dep-header-left {
+ display: flex !important;
+ align-items: center !important;
+ gap: 1.0vw !important;
+}
+
+.eva-dep-header-icon {
+ width: 5.5vh !important;
+ height: 5.5vh !important;
+ flex-shrink: 0 !important;
+ color: #03c5ff !important; /* Bleu cyan/azur vif EVA Départs (#03c5ff) */
+}
+
+.eva-dep-header-titles {
+ display: flex !important;
+ align-items: baseline !important;
+ gap: 0.6vw !important;
+ white-space: nowrap !important;
+}
+
+.eva-dep-header-title-main {
+ font-size: 5.2vh !important;
+ font-weight: 900 !important;
+ color: #ffffff !important;
+ letter-spacing: 0.04vw !important;
+ line-height: 1 !important;
+ text-transform: uppercase !important;
+}
+
+.eva-dep-header-slash {
+ font-size: 3.8vh !important;
+ font-weight: 700 !important;
+ color: #03c5ff !important;
+ line-height: 1 !important;
+}
+
+.eva-dep-header-title-sub {
+ font-size: 3.2vh !important;
+ font-weight: 700 !important;
+ color: #03c5ff !important;
+ letter-spacing: 0.02vw !important;
+ line-height: 1 !important;
+ text-transform: uppercase !important;
+}
+
+.eva-dep-header-right {
+ display: flex !important;
+ align-items: center !important;
+}
+
+/* Horloge EVA Départs */
+.eva-dep-clock-box {
+ border: 2.2px solid #03c5ff !important;
+ background-color: #000824 !important;
+ border-radius: 8px !important;
+ padding: 0.5vh 1.0vw !important;
+ display: flex !important;
+ align-items: baseline !important;
+ gap: 0.5vw !important;
+ box-shadow: 0 0 12px rgba(3, 197, 255, 0.25) !important;
+}
+
+.eva-dep-clock-main {
+ font-size: 4.8vh !important;
+ font-weight: 800 !important;
+ color: #ffffff !important;
+ line-height: 1 !important;
+ letter-spacing: 0.02vw !important;
+}
+
+.eva-dep-clock-seconds {
+ font-size: 3.8vh !important;
+ font-weight: 800 !important;
+ color: #03c5ff !important;
+ line-height: 1 !important;
+}
+
+/* ==========================================================================
+ LISTE DES HORAIRES (Cartes Départs)
+ ========================================================================== */
+.eva-dep-list {
+ flex: 1 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: flex-start !important;
+ gap: 1.2vh !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ list-style: none !important;
+ overflow: hidden !important;
+}
+
+.eva-dep-row {
+ background-color: #e9eef6 !important;
+ border-radius: 10px !important;
+ display: flex !important;
+ align-items: stretch !important;
+ height: calc((100% - 3.6vh) / 4) !important;
+ min-height: 12vh !important;
+ box-sizing: border-box !important;
+ overflow: hidden !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25) !important;
+}
+
+/* --------------------------------------------------------------------------
+ COLONNE GAUCHE : HEURE & STATUT
+ -------------------------------------------------------------------------- */
+.eva-dep-col-time {
+ width: 14.5vw !important;
+ min-width: 14.5vw !important;
+ max-width: 14.5vw !important;
+ background-color: #d7e1f0 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: center !important;
+ align-items: center !important;
+ padding: 0.8vh 0.6vw !important;
+ box-sizing: border-box !important;
+ border-top-left-radius: 10px !important;
+ border-bottom-left-radius: 10px !important;
+}
+
+.eva-dep-time-value {
+ font-size: 5.6vh !important;
+ font-weight: 900 !important;
+ color: #000c2f !important;
+ line-height: 1 !important;
+ margin-bottom: 0.6vh !important;
+ letter-spacing: -0.02vw !important;
+}
+
+/* Pastilles de statut */
+.eva-dep-status-pill {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ gap: 0.4vw !important;
+ padding: 0.3vh 0.9vw !important;
+ border-radius: 20px !important;
+ box-sizing: border-box !important;
+ height: 3.8vh !important;
+}
+
+.eva-dep-status-pill--ontime {
+ background-color: #d7e1f0 !important;
+ border: 1.8px solid #7d95b5 !important;
+ color: #1a2b4c !important;
+}
+
+.eva-dep-status-pill--delayed {
+ background-color: #ffa74f !important;
+ color: #000c2f !important;
+}
+
+.eva-dep-status-pill--suppressed {
+ background-color: #ff5c73 !important;
+ color: #ffffff !important;
+}
+
+.eva-dep-status-icon {
+ width: 2.5vh !important;
+ height: 2.5vh !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-dep-status-text {
+ font-size: 2.2vh !important;
+ font-weight: 700 !important;
+ line-height: 1 !important;
+ white-space: nowrap !important;
+}
+
+/* --------------------------------------------------------------------------
+ SECTION CENTRALE : DESTINATION & GARES DESSERVIES (VIAS)
+ -------------------------------------------------------------------------- */
+.eva-dep-col-center {
+ flex: 1 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: center !important;
+ padding: 0.6vh 1.8vw !important;
+ box-sizing: border-box !important;
+ overflow: hidden !important;
+ min-width: 0 !important;
+}
+
+.eva-dep-origin-row {
+ display: flex !important;
+ align-items: center !important;
+ gap: 0.8vw !important;
+ overflow: hidden !important;
+ white-space: nowrap !important;
+ margin-bottom: 0.4vh !important;
+}
+
+.eva-dep-origin-name {
+ font-size: 5.6vh !important;
+ font-weight: 900 !important;
+ color: #000c2f !important;
+ line-height: 1.1 !important;
+ letter-spacing: -0.02vw !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+}
+
+/* Badge Motif de Perturbation (ex: Travaux, Panne) */
+.eva-dep-perturb-badge {
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: 0.4vw !important;
+ background-color: #ffa74f !important;
+ color: #000c2f !important;
+ padding: 0.4vh 0.8vw !important;
+ border-radius: 6px !important;
+ height: 3.4vh !important;
+ box-sizing: border-box !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-dep-perturb-badge-icon {
+ width: 2.4vh !important;
+ height: 2.4vh !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-dep-perturb-badge-text {
+ font-size: 2.2vh !important;
+ font-weight: 700 !important;
+ line-height: 1 !important;
+ white-space: nowrap !important;
+}
+
+/* Section Vias */
+.eva-dep-vias-container {
+ display: flex !important;
+ align-items: baseline !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ width: 100% !important;
+}
+
+.eva-dep-vias-label {
+ font-size: 3.5vh !important;
+ font-weight: 700 !important;
+ color: #03c5ff !important; /* Bleu azur EVA Départs (#03c5ff) */
+ margin-right: 0.5vw !important;
+ flex-shrink: 0 !important;
+}
+
+.eva-dep-vias-scroll-wrapper {
+ flex: 1 !important;
+ overflow: hidden !important;
+ white-space: nowrap !important;
+ position: relative !important;
+ display: flex !important;
+ align-items: baseline !important;
+}
+
+.eva-dep-vias-inner {
+ display: inline-flex !important;
+ align-items: baseline !important;
+ white-space: nowrap !important;
+}
+
+.eva-dep-vias-inner--animating {
+ animation-name: eva-dep-vias-marquee !important;
+ animation-timing-function: linear !important;
+ animation-iteration-count: infinite !important;
+ padding-left: 20% !important;
+}
+
+@keyframes eva-dep-vias-marquee {
+ 0% {
+ transform: translateX(0%);
+ }
+ 100% {
+ transform: translateX(-100%);
+ }
+}
+
+.eva-dep-vias-stop {
+ font-size: 3.5vh !important;
+ font-weight: 500 !important;
+ color: #2c3b57 !important;
+ line-height: 1 !important;
+}
+
+.eva-dep-vias-bullet {
+ color: #03c5ff !important;
+ font-weight: 800 !important;
+ margin: 0 0.5vw !important;
+ font-size: 2.8vh !important;
+ display: inline-block !important;
+ vertical-align: middle !important;
+}
+
+/* --------------------------------------------------------------------------
+ SECTION DROITE : MODE DE TRANSPORT / VOIE & OPERATEUR / NUMERO
+ -------------------------------------------------------------------------- */
+.eva-dep-col-right {
+ display: flex !important;
+ align-items: center !important;
+ height: 100% !important;
+ flex-shrink: 0 !important;
+}
+
+/* Boîte Picto Autocar/Bus */
+.eva-dep-bus-box {
+ background-color: #000c2f !important;
+ border-radius: 8px !important;
+ width: 7.0vw !important;
+ height: 80% !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ margin-right: 0.8vw !important;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important;
+}
+
+.eva-dep-bus-icon {
+ width: 6.0vh !important;
+ height: 6.0vh !important;
+ color: #ffffff !important;
+}
+
+/* Boîte Voie (si applicable pour train) */
+.eva-dep-voie-box {
+ background-color: #000c2f !important;
+ border-radius: 8px !important;
+ width: 7.0vw !important;
+ height: 80% !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ margin-right: 0.8vw !important;
+ color: #ffffff !important;
+}
+
+.eva-dep-voie-label {
+ font-size: 1.8vh !important;
+ font-weight: 600 !important;
+ text-transform: uppercase !important;
+ opacity: 0.8 !important;
+}
+
+.eva-dep-voie-val {
+ font-size: 4.5vh !important;
+ font-weight: 900 !important;
+ line-height: 1 !important;
+}
+
+/* Bloc Opérateur & Numéro Bleu Azur Départs */
+.eva-dep-train-block {
+ background-color: #03c5ff !important; /* Bleu cyan/azur #03c5ff */
+ width: 10.0vw !important;
+ min-width: 10.0vw !important;
+ height: 100% !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ border-top-right-radius: 10px !important;
+ border-bottom-right-radius: 10px !important;
+ padding: 0 0.5vw !important;
+ box-sizing: border-box !important;
+}
+
+.eva-dep-train-op {
+ font-size: 2.8vh !important;
+ font-weight: 700 !important;
+ color: #000c2f !important;
+ line-height: 1 !important;
+ margin-bottom: 0.3vh !important;
+ text-transform: uppercase !important;
+}
+
+.eva-dep-train-num {
+ font-size: 4.2vh !important;
+ font-weight: 900 !important;
+ color: #000c2f !important;
+ line-height: 1 !important;
+ letter-spacing: 0.02vw !important;
+}
+
+/* ==========================================================================
+ BANDEAU TRAVAUX / ALERTES (Pied de page)
+ ========================================================================== */
+.eva-dep-bottom-alert {
+ background-color: #fca758 !important;
+ border-radius: 10px !important;
+ display: flex !important;
+ align-items: center !important;
+ padding: 0.8vh 1.8vw !important;
+ margin-top: 1.0vh !important;
+ gap: 1.4vw !important;
+ box-sizing: border-box !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
+ flex-shrink: 0 !important;
+ height: 10.8vh !important;
+ min-height: 10.8vh !important;
+ max-height: 10.8vh !important;
+}
+
+.eva-dep-bottom-alert-icon-wrapper {
+ flex-shrink: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+}
+
+.eva-dep-bottom-alert-icon {
+ width: 5.8vh !important;
+ height: 5.8vh !important;
+ color: #000c2f !important;
+}
+
+.eva-dep-bottom-alert-container {
+ flex: 1 !important;
+ overflow: hidden !important;
+ position: relative !important;
+ height: 9.0vh !important;
+ max-height: 9.0vh !important;
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: flex-start !important;
+}
+
+.eva-dep-bottom-alert-content {
+ color: #000c2f !important;
+ font-size: 2.4vh !important;
+ font-weight: 600 !important;
+ line-height: 1.25 !important;
+ white-space: normal !important;
+ word-break: break-word !important;
+ will-change: transform, opacity !important;
+}
diff --git a/src/pages/afficheurs/eva/EVADeparts.js b/src/pages/afficheurs/eva/EVADeparts.js
new file mode 100644
index 0000000..7f56138
--- /dev/null
+++ b/src/pages/afficheurs/eva/EVADeparts.js
@@ -0,0 +1,1373 @@
+import React, { useEffect, useState, useCallback, useRef } from 'react';
+import './EVADeparts.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';
+
+// 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()).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) return `${String(m[1]).padStart(2, '0')}:${m[2]}`;
+ const m2 = v.match(/T(\d{2}):(\d{2})/);
+ if (m2) return `${m2[1]}:${m2[2]}`;
+ return v.slice(0, 5).replace('h', ':');
+ }
+ return String(v).slice(0, 5).replace('h', ':');
+};
+
+// Icône Passager + Face avant de train (Logo En-tête EVA Départs)
+const EVADeparturesHeaderIcon = ({ size = '5.5vh', color = '#03c5ff', style = {} }) => (
+
+ {/* Voyageur avec valise */}
+
+ {/* Corps et jambes */}
+
+ {/* Bras tirant la valise */}
+
+ {/* Valise à roulettes */}
+
+
+
+
+ {/* Train de face */}
+
+ {/* Pare-brise (découpé en bleu nuit) */}
+
+ {/* Feux avant du train */}
+
+
+ {/* Grille / Ligne basse du train */}
+
+ {/* Rails au sol */}
+
+
+
+);
+
+// Icône de Bus / Autocar SNCF
+const EVABusIcon = ({ size = '6.0vh', color = '#ffffff', style = {} }) => (
+
+
+
+);
+
+// Icône Information (Badge motif perturbation)
+const EVAInfoIcon = ({ size = '2.4vh', color = '#000c2f', style = {} }) => (
+
+
+
+
+
+);
+
+// Icône Information Grande (Bandeau Travaux inférieur)
+const EVABigAlertIcon = ({ size = '5.8vh', color = '#000c2f', style = {} }) => (
+
+
+
+
+
+
+);
+
+// Icône Horloge pour les pastilles de statut
+const EVAClockIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => (
+
+
+
+
+);
+
+// Icône Suppression (Croix)
+const EVACrossIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => (
+
+
+
+
+
+);
+
+// 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 (
+
+ Direct
+
+ );
+ }
+
+ return (
+
+
via
+
+
+ {viaNames.map((v, i) => (
+
+ {i > 0 && • }
+ {v}
+
+ ))}
+
+
+
+ );
+};
+
+// 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 = {
+ ' ': ' ',
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': "'",
+ '’': "’",
+ '‘': "‘",
+ ''': "'",
+ '«': '«',
+ '»': '»',
+ 'À': 'À', 'Â': 'Â', 'Ä': 'Ä', 'É': 'É', 'È': 'È', 'Ê': 'Ê', 'Ë': 'Ë',
+ 'Î': 'Î', 'Ï': 'Ï', 'Ô': 'Ô', 'Ö': 'Ö', 'Ù': 'Ù', 'Û': 'Û', 'Ü': 'Ü',
+ 'Ç': 'Ç',
+ 'à': 'à', 'â': 'â', 'ä': 'ä', 'é': 'é', 'è': 'è', 'ê': 'ê', 'ë': 'ë',
+ 'î': 'î', 'ï': 'ï', 'ô': 'ô', 'ö': 'ö', 'ù': 'ù', 'û': 'û', 'ü': 'ü',
+ 'ç': 'ç'
+ };
+ 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 (
+
+ );
+};
+
+// Horloge temps réel EVA Départs (HH:MM en blanc, SS en bleu azur 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 (
+
+ {hh}:{mm}
+ {ss}
+
+ );
+}
+
+export default function EVADeparts() {
+ // 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, 'EVADeparts');
+ useAutoAnnonce(horaires, currentTime, false);
+
+ // Récupération type de gare
+ useEffect(() => {
+ let mounted = true;
+ const fetchStationType = async () => {
+ 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 */ }
+ };
+ if (supabase) {
+ 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_depart || 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 Supabase avec polling
+ useEffect(() => {
+ let mounted = true;
+ let inFlight = false;
+
+ const fetchData = async () => {
+ if (!mounted || inFlight) return;
+ inFlight = true;
+ 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 => {
+ // Exclure si la gare ciblée est la gare d'arrivée finale (terminus)
+ const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());
+ if (destName === normalized) return false;
+
+ // Inclure si gare de départ correspond
+ const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString());
+ if (departName === normalized) return true;
+
+ // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus
+ const stopsList = getStops(h);
+ if (Array.isArray(stopsList) && stopsList.length) {
+ let maxIdx = null;
+ for (const st of stopsList) {
+ if (typeof st.index !== 'undefined' && st.index !== null) {
+ const n = Number(st.index);
+ if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);
+ }
+ }
+ for (const s of stopsList) {
+ const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString());
+ if (!stopName) continue;
+ const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
+ if (isTerminus) continue;
+ if (stopName === normalized) return true;
+ }
+ }
+ return false;
+ });
+ } else if (gareIdParam) {
+ const gid = String(gareIdParam);
+ displayHoraires = displayHoraires.filter(h => {
+ // Exclure si gare d'arrivée finale correspond
+ const destId = (h.gare_arrivee_id || h.gare_arrivee || null);
+ if (destId != null && String(destId) === gid) return false;
+
+ // Inclure si gare de départ correspond
+ const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;
+ if (departId != null && String(departId) === gid) return true;
+
+ // Chercher dans les arrêts desservis (ignorer terminus)
+ const stopsList = getStops(h);
+ if (Array.isArray(stopsList) && stopsList.length) {
+ let maxIdx = null;
+ for (const st of stopsList) {
+ if (typeof st.index !== 'undefined' && st.index !== null) {
+ const n = Number(st.index);
+ if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);
+ }
+ }
+ 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 isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
+ if (isTerminus) 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;
+ const m = String(s).match(/(\d{1,2}):(\d{2})/);
+ if (m) return parseInt(m[1], 10) * 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_depart || 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_depart || stop.time || stop.heure)) raw = stop.heure_depart || 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 - 1) 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);
+
+ setHoraires(annotated || []);
+ setPerturbations(filteredPerturb || []);
+ setTravaux(travauxData || []);
+ setTravauxSubstitutions(travauxSubstData || []);
+ } catch (err) {
+ console.error('Erreur Supabase EVADeparts', 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, 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);
+ }, []);
+
+ // Pagination : 4 trains par page sur les écrans EVA
+ const ITEMS_PER_PAGE = 4;
+ const totalPages = Math.max(1, Math.ceil(horaires.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 displayedHoraires = horaires.slice(pageIndex * ITEMS_PER_PAGE, (pageIndex + 1) * ITEMS_PER_PAGE);
+
+ // Recherche d'un message de travaux ou d'alerte pour le bandeau bas
+ const activeTravauxMessage = (() => {
+ 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;
+ }
+ }
+ 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 (
+
+ {/* HEADER */}
+
+
+ {/* LISTE DES TRAINS (Cartes Départs) */}
+
+
+ {/* BANDEAU BAS : INFORMATION TRAVAUX / PERTURBATION AVEC SCROLL PROGRESSIF */}
+ {activeTravauxMessage && (
+
+ )}
+
+ );
+}