import React, { useEffect, useState, useCallback, useRef } from 'react'; import './EVAArrivees.css'; import supabase from '../../../lib/supabase'; import { fetchAllRows } from '../../../lib/fetchAllRows'; import MarqueeText from '../../../components/MarqueeText'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import { getSourceFromPath } from '../../../services/railwayService'; import { getSncfTimetable } from '../../../services/sncfApi'; // Helper utilitaire : format HH:MM avec deux points (ex: 22:39) const formatTimeWithColon = (v) => { if (v == null) return ''; const tryDate = new Date(v); if (!isNaN(tryDate.getTime())) { const hh = String(tryDate.getHours() % 24).padStart(2, '0'); const mm = String(tryDate.getMinutes()).padStart(2, '0'); return `${hh}:${mm}`; } if (typeof v === 'string') { const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/); if (m) { const h = parseInt(m[1], 10) % 24; return `${String(h).padStart(2, '0')}:${m[2]}`; } const m2 = v.match(/T(\d{2}):(\d{2})/); if (m2) { const h = parseInt(m2[1], 10) % 24; return `${String(h).padStart(2, '0')}:${m2[2]}`; } const m3 = v.match(/(\d{1,2})h(\d{2})/i); if (m3) { const h = parseInt(m3[1], 10) % 24; return `${String(h).padStart(2, '0')}:${m3[2]}`; } return v.slice(0, 5).replace('h', ':'); } return String(v).slice(0, 5).replace('h', ':'); }; // Icône Passager avec valise à roulettes + Face avant de train (Logo En-tête EVA Arrivées) const EVAArrivalsHeaderIcon = ({ size = '59px', color = '#00d68f', style = {} }) => ( {/* 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 = '65px', color = '#ffffff', style = {} }) => ( ); // Icône Information (Badge motif perturbation) const EVAInfoIcon = ({ size = '26px', color = '#000c2f', style = {} }) => ( ); // Icône Information Grande (Bandeau Travaux inférieur) const EVABigAlertIcon = ({ size = '63px', 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 = '27px', color = 'currentColor', style = {} }) => ( ); // Icône Suppression (Croix) const EVACrossIcon = ({ size = '27px', 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'); // Récupération type de gare useEffect(() => { let mounted = true; const source = getSourceFromPath(window.location.pathname); const fetchStationType = async () => { if (source === 'sncf') { if (mounted) { setStationType('ville'); setResolvedGareId(null); } return; } try { let fetchedType = null; let fetchedId = gareIdParam || null; if (gareIdParam) { const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1); if (!gerr && gdata && gdata.length) { fetchedType = gdata[0] && gdata[0].type; fetchedId = gdata[0] && gdata[0].id; } } else if (gareDisplay) { const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); if (!gerr && gdata && gdata.length) { const exactMatch = gdata.find(g => { const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); return n1 === n2; }); const chosen = exactMatch || gdata[0]; fetchedType = chosen ? chosen.type : null; fetchedId = chosen ? chosen.id : null; } } if (mounted) { setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); setResolvedGareId(fetchedId); } } catch (e) { /* ignore */ } }; fetchStationType(); return () => { mounted = false; }; }, [gareIdParam, gareDisplay]); // Helpers pour perturbations const normalizeName = useCallback((s) => { if (!s) return ''; try { const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' '); } catch (e) { return String(s).toLowerCase().trim(); } }, []); const safeParseArrets = useCallback((p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return []; if (Array.isArray(raw)) return raw; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed.arrets)) return parsed.arrets; return parsed; } return []; } catch (e) { return []; } }, []); const getStops = useCallback((h) => { if (!h) return []; if (Array.isArray(h.stops) && h.stops.length) return h.stops; if (Array.isArray(h.gares_desservies) && h.gares_desservies.length) return h.gares_desservies; if (Array.isArray(h.garesDesservies) && h.garesDesservies.length) return h.garesDesservies; const candidates = ['stops', 'gares_desservies', 'garesDesservies', 'gares_desservies_json']; for (const key of candidates) { const raw = h[key]; if (!raw) continue; if (Array.isArray(raw)) return raw; if (typeof raw === 'string') { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ } } } return []; }, []); const parsePerturbArretsObject = useCallback((p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return { arrets: [], jours: null }; if (typeof raw === 'object') { if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null }; if (Array.isArray(raw)) return { arrets: raw, jours: null }; return { arrets: [], jours: null }; } try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null }; } return { arrets: [], jours: null }; } catch (e) { return { arrets: [], jours: null }; } }, []); const getPerturbationJours = useCallback((p) => { if (!p) return null; const candidate = p.jours_perturbation || p.jours || null; const result = new Set(); const pushDate = (d) => { if (!d) return; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0, 10)); } catch (e) {} }; if (candidate) { if (Array.isArray(candidate)) { candidate.forEach(item => pushDate(item.date || item)); } else if (typeof candidate === 'string') { try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(candidate); } } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); else pushDate(candidate.date || null); } } try { const parsedObj = parsePerturbArretsObject(p); const jp = parsedObj.jours; if (jp) { if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); else if (typeof jp === 'string') { try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } } else if (typeof jp === 'object') pushDate(jp.date || null); } } catch (e) {} return result.size ? result : null; }, [parsePerturbArretsObject]); const parseDelayMinutesFromArret = useCallback((arret) => { if (!arret) return null; const keys = ['retard_minutes', 'retard', 'delay_minutes', 'delay', 'retard_min']; for (const k of keys) { if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n); } } return null; }, []); const buildPerturbMap = useCallback((list = perturbations, horairesList = allHoraires) => { const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] }; (list || []).forEach(p => { const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID; if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); } const number = p.number || p.train_number || p.train || p.numero || p.numero_train; if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); } const arrets = safeParseArrets(p); if (Array.isArray(arrets) && arrets.length) { arrets.forEach(a => { const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code; const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); } if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); } }); } if ((horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); } }); if (Array.isArray(horairesList) && horairesList.length) { try { const byH = Object.keys(idx.byHoraire || {}); if (byH && byH.length) { for (const hid of byH) { const h = horairesList.find(x => String(x.id || x.horaire_id || x.horaireId || x.numero) === String(hid)); if (h) { const num = h.numero_train || h.number || h.numero || null; if (num != null) { const numKey = String(num); idx.byNumber[numKey] = idx.byNumber[numKey] || []; (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); } } } } } catch (e) {} } return idx; }, [perturbations, allHoraires, safeParseArrets, normalizeName]); const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { const map = idx || buildPerturbMap(); const allPerturbs = []; const hId = horaire && (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train); const number = horaire && (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number); if (map) { const addedIds = new Set(); const addP = (p) => { if (p && !addedIds.has(p.id)) { addedIds.add(p.id); allPerturbs.push(p); } }; if (hId != null && map.byHoraire && map.byHoraire[String(hId)]) { map.byHoraire[String(hId)].forEach(addP); } if (number != null && map.byNumber && map.byNumber[String(number)]) { map.byNumber[String(number)].forEach(addP); } if (stationId != null && map.byStopId && map.byStopId[String(stationId)]) { map.byStopId[String(stationId)].forEach(x => addP(x.perturbation)); } if (stationName && map.byStopName) { const key = normalizeName(stationName); if (map.byStopName[key]) map.byStopName[key].forEach(x => addP(x.perturbation)); for (const existingKey of Object.keys(map.byStopName)) { if (existingKey.includes(key) || key.includes(existingKey)) { map.byStopName[existingKey].forEach(x => addP(x.perturbation)); } } } const trainStops = getStops(horaire); if (Array.isArray(trainStops)) { trainStops.forEach(s => { const sId = s.gare_id || s.gareId || s.id || s.code; const sName = s.gare_nom || s.nom || s.name || s.stop_name; if (sId != null && map.byStopId && map.byStopId[String(sId)]) { map.byStopId[String(sId)].forEach(x => addP(x.perturbation)); } if (sName && map.byStopName) { const skey = normalizeName(sName); if (map.byStopName[skey]) map.byStopName[skey].forEach(x => addP(x.perturbation)); } }); } if (map.global) { map.global.forEach(addP); } } const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); }; let horaireYmd = toYMD(horaire && (horaire._date || horaire.date || horaire.date_service || horaire.service_date)); if (!horaireYmd && serviceDate) horaireYmd = serviceDate; let maxDelay = null; let isSuppressed = false; let causeDetails = ''; for (const p of allPerturbs) { try { if (horaireYmd) { const pDate = toYMD(p.date || p.date_service); const db = toYMD(p.date_debut || p.start_date); const df = toYMD(p.date_fin || p.end_date); if (pDate && pDate !== horaireYmd) continue; if (db && df && !(horaireYmd >= db && horaireYmd <= df)) continue; } } catch (e) {} try { const joursSet = getPerturbationJours(p); if (joursSet && horaireYmd && !joursSet.has(horaireYmd)) continue; } catch (e) {} const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero; const numberRef = p.number || p.train_number || p.numero || p.numero_train; const appliesByRef = ref && (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero)); const appliesByNumber = numberRef && (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number)); const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; const arrets = parsedObj.arrets || []; const trainStops = getStops(horaire); let trainStopMatch = false; const matchesTarget = (a) => { if (!stationName && stationId == null) return false; const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code; const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; if (stationId != null && String(aId) === String(stationId)) return true; if (stationName && aName) { const na = normalizeName(aName); const ns = normalizeName(stationName); return na === ns; } return false; }; for (const a of arrets) { const aId = a.gare_id || a.gareId || a.id || a.uic || a.code; const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; const na = normalizeName(aName || ''); const isTarget = matchesTarget(a); let isTrainStop = false; if (trainStops && Array.isArray(trainStops)) { isTrainStop = trainStops.some(s => { const sId = s.gare_id || s.gareId || s.id || s.code; const sName = s.gare_nom || s.nom || s.name || s.stop_name; if (sId != null && aId != null && String(sId) === String(aId)) return true; if (sName && aName) { const ns = normalizeName(sName); return ns === na; } return false; }); } const originName = normalizeName(horaire.gare_depart_nom || horaire.gare_depart || ''); const destName = normalizeName(horaire.gare_arrivee_nom || horaire.gare_arrivee || ''); if (originName === na || destName === na) isTrainStop = true; if (isTrainStop) trainStopMatch = true; if (isTarget) { const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) { isSuppressed = true; } } } const isGlobal = (arrets.length === 0) && !ref && !numberRef; const applies = appliesByRef || appliesByNumber || trainStopMatch || isGlobal; if (applies) { const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) { isSuppressed = true; } for (const a of arrets) { const tType = (a.type_perturbation || a.type || '').toString().toLowerCase(); if (tType.includes('retard') || tType.includes('delay') || /retard/.test(tType)) { const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } // Extraction du motif / cause de la perturbation const cause = p.cause_details || p.motif || p.cause || p.description || p.information || p.details || ''; if (cause) { causeDetails = cause; } } } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { const map = buildPerturbMap(perturbList || []); const targetStationId = displayStationId || gareIdParam || null; const targetStationName = displayStationName || gareDisplay || null; return (list || []).map(h => { const horaire = { ...h }; const stops = getStops(horaire); const stopsStatus = []; for (let i = 0; i < stops.length; i++) { const s = stops[i] || {}; const stationId = s.gare_id || s.gareId || s.id || s.station_id || s.stationId || s.uic || s.code; const stationName = s.gare_nom || s.nom || s.name || s.station_name || s.station_nom; const st = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate); stopsStatus.push({ stationId, stationName, status: st }); } const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); let displayTime = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || ''); horaire._stopsStatus = stopsStatus; const stopForStation = (stops || []).find(s => { const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; const ns = normalizeName(nm || ''); const target = normalizeName(targetStationName || ''); return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target); }) || null; horaire._status = displayStatus; horaire._displayTime = displayTime; const platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || ''; horaire._displayPlatform = String(platformVal).trim(); return horaire; }); }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); // Récupération des données (SNCF GTFS ou Supabase) avec polling useEffect(() => { let mounted = true; let inFlight = false; const source = getSourceFromPath(window.location.pathname); const fetchData = async () => { if (!mounted || inFlight) return; inFlight = true; if (source === 'sncf') { setLoading(true); setError(null); try { const { data: sncfData, error: sncfErr } = await getSncfTimetable({ gareName: gareDisplay || gareParam, mode: 'arrivees' }); if (!mounted) { inFlight = false; return; } if (sncfErr) { setError(sncfErr); } else { const formatted = (sncfData || []).map(t => { const timeStr = t.heure_arrivee || t.heure_depart || t.time || ''; return { ...t, _displayTime: formatTimeWithColon(timeStr), _displayPlatform: t.quai || t.voie || '', _arrHM: t._arrHM || 0, _status: t._status || { label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"), detail: '', delayMinutes: t.retard_minutes || null, causeDetails: t.cause_retard || null } }; }); setHoraires(formatted.slice(0, 10)); setAllHoraires(formatted); } } catch (e) { if (mounted) setError(e.message || String(e)); } finally { if (mounted) setLoading(false); inFlight = false; } return; } if (!supabase) { setLoading(false); inFlight = false; return; } setLoading(true); setError(null); try { const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }, { data: travauxSubstData }] = await Promise.all([ fetchAllRows(supabase.from('horaires').select('*')), supabase.from('perturbations').select('*'), supabase.from('travaux').select('*'), supabase.from('travaux_substitutions').select('*') ]); if (!mounted) { inFlight = false; return; } if (hError || pError || tError) { if (mounted) setError((hError || pError || tError).message || String(hError || pError || tError)); setLoading(false); inFlight = false; return; } let parsedHoraires = (horairesData || []).map(h => h || {}); parsedHoraires = parsedHoraires.map(h => { try { if (h && typeof h.gares_desservies === 'string') { const parsed = JSON.parse(h.gares_desservies); if (Array.isArray(parsed)) return { ...h, gares_desservies: parsed }; } } catch (e) {} return h; }); if (mounted) setAllHoraires(parsedHoraires || []); let displayHoraires = parsedHoraires.slice(); if (gareDisplay) { const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' ')); displayHoraires = displayHoraires.filter(h => { const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); if (departName === normalized) return false; const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); if (arriveeName === normalized) return true; const stopsList = getStops(h); if (Array.isArray(stopsList) && stopsList.length) { for (const s of stopsList) { const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); if (!stopName) continue; const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0)); if (isOrigin) continue; if (stopName === normalized) return true; } } return false; }); } else if (gareIdParam) { const gid = String(gareIdParam); displayHoraires = displayHoraires.filter(h => { const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; if (departId != null && String(departId) === gid) return false; const destId = (h.gare_arrivee_id || h.gare_arrivee || null); if (destId != null && String(destId) === gid) return true; const stopsList = getStops(h); if (Array.isArray(stopsList) && stopsList.length) { for (const s of stopsList) { const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; if (stopId == null) continue; const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0)); if (isOrigin) continue; if (String(stopId) === gid) return true; } } return false; }); } const dateKeys = ['date_depart', 'date', 'jour', 'service_date', 'date_service', 'date_dep']; const normalizeDate = (o) => { for (const k of dateKeys) { if (!o) continue; const v = o[k]; if (!v) continue; const d = new Date(v); if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10); } return null; }; const withDates = displayHoraires.map(h => ({ ...h, _date: normalizeDate(h) })); const parseTimeHM = (s) => { if (!s) return null; if (typeof s === 'number') return s % 1440; const m = String(s).match(/(\d{1,2})[:h](\d{2})/i); if (m) { const h = parseInt(m[1], 10) % 24; return h * 60 + parseInt(m[2], 10); } const d = new Date(s); if (!isNaN(d.getTime())) return d.getHours() * 60 + d.getMinutes(); return null; }; const now = new Date(); const todayHM = now.getHours() * 60 + now.getMinutes(); const enrich = withDates.map(h => { let raw = h.heure_arrivee || h.time || h.heure || ''; const stopsListForH = getStops(h); if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) { const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null; const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; const stop = byId || byName; if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure; } const depHM = parseTimeHM(raw); const displayTime = formatTimeWithColon(raw); return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; }); const pad = (n) => String(n).padStart(2, '0'); const localDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const today = new Date(); const todayStr = localDateStr(today); const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); const tomorrowStr = localDateStr(tomorrow); const circulatesOn = (h, d) => { const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const customDaysRaw = h.jours_personnalises; if (!customDaysRaw) return false; let customDays = []; if (Array.isArray(customDaysRaw)) { customDays = customDaysRaw; } else if (typeof customDaysRaw === 'string') { try { customDays = JSON.parse(customDaysRaw); } catch (e) { return false; } } if (Array.isArray(customDays)) { const match = customDays.find(item => item.date === targetDateStr); if (match) return Boolean(match.circule); } return false; }; const travauxList = travauxData || []; const travauxSubstList = travauxSubstData || []; const parseDateOnly = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }; const parseJoursTravaux = (val) => { if (val == null) return null; if (Array.isArray(val)) return new Set(val.map(x => String(x).toLowerCase())); const s = String(val || ''); const parts = s.split(/[,;\s]+/).map(p => p.trim()).filter(Boolean); return parts.length ? new Set(parts.map(p => p.toLowerCase())) : null; }; const weekdayShort = (i) => { switch (i) { case 0: return 'dim'; case 1: return 'lun'; case 2: return 'mar'; case 3: return 'mer'; case 4: return 'jeu'; case 5: return 'ven'; case 6: return 'sam'; default: return null; } }; const travauxApplies = (t, h, date) => { if (!t) return false; const db = parseDateOnly(t.date_debut || t.dateDebut || t.start_date); const df = parseDateOnly(t.date_fin || t.dateFin || t.end_date); if (!db || !df) return false; const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()); if (d < db || d > df) return false; const joursSet = parseJoursTravaux(t.jours_travaux || t.jours || t.days); if (joursSet) { const name = weekdayShort(d.getDay()); if (!name) return false; let ok = false; for (const v of joursSet) { if (v.includes(name) || name.includes(v)) { ok = true; break; } } if (!ok) return false; } const startHM = parseTimeHM(t.heure_debut || t.heureDebut || t.start_time); const endHM = parseTimeHM(t.heure_fin || t.heureFin || t.end_time); if (startHM == null || endHM == null) return true; if (typeof h._depHM === 'number') return h._depHM >= startHM && h._depHM <= endHM; return false; }; const isSubstitution = (h) => { if (!h) return false; return travauxSubstList.some(ts => String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero)); }; const enriched = enrich.map(h => { let linked = false; let appliesToday = false; let appliesTomorrow = false; for (const t of travauxList) { const tLine = t.ligne_id || t.ligne || t.line_id || t.line || t.ligneId || t.lineId; const hLine = h.ligne_id || h.ligne || h.line_id || h.line || h.ligneId || h.lineId; if (typeof tLine !== 'undefined' && tLine !== null) { if (typeof hLine === 'undefined' || hLine === null) continue; if (String(tLine) !== String(hLine)) continue; } const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero; if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true; const substForThisTravail = travauxSubstList.some(ts => String(ts.travaux_id) === String(t.id) && (String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero))); if (substForThisTravail) linked = true; if (!appliesToday && travauxApplies(t, h, today)) appliesToday = true; if (!appliesTomorrow && travauxApplies(t, h, tomorrow)) appliesTomorrow = true; if (linked && appliesToday && appliesTomorrow) break; } return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow }; }); const todayCandidates = enriched.filter(h => { if (isSubstitution(h)) { return Boolean(h._linkedToTravaux && h._travauxAppliesToday); } if (h._date && h._date !== todayStr) return false; if (!circulatesOn(h, today)) return false; if (typeof h._depHM !== 'number') return false; if (h._depHM < todayHM) return false; if (h._travauxAppliesToday) return false; return true; }); const tomorrowCandidates = enriched.filter(h => { if (isSubstitution(h)) { return Boolean(h._linkedToTravaux && h._travauxAppliesTomorrow); } if (h._date && h._date !== tomorrowStr) return false; if (!circulatesOn(h, tomorrow)) return false; if (h._travauxAppliesTomorrow) return false; return true; }); let finalList; let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); serviceDateStr = tomorrowStr; } const filteredPerturb = (perturbData || []).filter(p => { try { const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); }; const pDate = toYMD(p.date || p.date_service); const db = toYMD(p.date_debut || p.start_date); const df = toYMD(p.date_fin || p.end_date); if (pDate && pDate !== serviceDateStr) return false; if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(serviceDateStr)) return false; } catch (e) {} return true; } catch (e) { return true; } }); const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr); const withServiceDate = (annotated || []).map(a => ({ ...a, _serviceDate: serviceDateStr })); setHoraires(withServiceDate || []); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); setTravauxSubstitutions(travauxSubstData || []); } catch (err) { console.error('Erreur Supabase EVAArrivees', err); if (mounted) setError(err.message || String(err)); } finally { if (mounted) setLoading(false); inFlight = false; } }; fetchData(); const intervalId = setInterval(fetchData, 15000); return () => { mounted = false; clearInterval(intervalId); }; }, [gareDisplay, gareParam, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]); // Alternance retard / texte toutes les 2s const [alternateDelay, setAlternateDelay] = useState(false); useEffect(() => { const delayTimer = setInterval(() => { setAlternateDelay(prev => !prev); }, 2000); return () => clearInterval(delayTimer); }, []); // Filtrage en temps réel : ne plus afficher les horaires dont l'heure est dépassée const currentHM = currentTime.getHours() * 60 + currentTime.getMinutes(); const pad = (n) => String(n).padStart(2, '0'); const todayDateStr = `${currentTime.getFullYear()}-${pad(currentTime.getMonth() + 1)}-${pad(currentTime.getDate())}`; const activeHoraires = (horaires || []).filter(h => { const sDate = h._serviceDate || h._date; if (!sDate || sDate === todayDateStr) { if (typeof h._depHM === 'number') { const delay = (h._status && typeof h._status.delayMinutes === 'number') ? h._status.delayMinutes : 0; const effectiveHM = h._depHM + delay; if (effectiveHM < currentHM) { return false; } } } return true; }); useAutoAnnonce(activeHoraires, currentTime, true); // Pagination : 4 trains par page sur les écrans EVA const ITEMS_PER_PAGE = 4; const totalPages = Math.max(1, Math.ceil(activeHoraires.length / ITEMS_PER_PAGE)); const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0); useEffect(() => { if (lockedPageIndex !== null) { setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); return; } const pageInterval = setInterval(() => { setPageIndex((prev) => { if (totalPages <= 1) return 0; return (prev + 1) % totalPages; }); }, 10000); // 10 secondes par page return () => clearInterval(pageInterval); }, [lockedPageIndex, totalPages]); const safePageIndex = Math.min(pageIndex, Math.max(0, totalPages - 1)); const displayedHoraires = activeHoraires.slice(safePageIndex * ITEMS_PER_PAGE, (safePageIndex + 1) * ITEMS_PER_PAGE); // Recherche d'un message de travaux ou d'alerte pour le bandeau bas const activeTravauxMessage = (() => { // Chercher un message explicite dans travaux if (Array.isArray(travaux) && travaux.length > 0) { const activeT = travaux.find(t => { const txt = t.message || t.description || t.texte || t.motif || t.details || t.titre || t.commentaire || ''; return Boolean(txt && txt.trim().length > 0); }); if (activeT) { return activeT.message || activeT.description || activeT.texte || activeT.motif || activeT.details || activeT.titre || activeT.commentaire; } } // Chercher dans perturbations générales if (Array.isArray(perturbations) && perturbations.length > 0) { const activeP = perturbations.find(p => { const txt = p.message || p.description || p.texte || p.cause_details || ''; return Boolean(txt && txt.trim().length > 0); }); if (activeP) { return activeP.message || activeP.description || activeP.texte || activeP.cause_details; } } return null; })(); return (
{/* HEADER */}
Arrivées / Arrivals From / Ankünfte Aus
{/* LISTE DES TRAINS (Cartes) */} {/* BANDEAU BAS : INFORMATION TRAVAUX / PERTURBATION AVEC SCROLL PROGRESSIF */} {activeTravauxMessage && ( )}
); }