/* eslint-disable */ import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesDeparts.css'; import supabase from '../../../lib/supabase'; import MarqueeText from '../../../components/MarqueeText'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import { fetchAllRows } from '../../../lib/fetchAllRows'; import { getSourceFromPath } from '../../../services/railwayService'; import { getSncfTimetable, resolveNominalTrack } from '../../../services/sncfApi'; import { matchStationNameStrict } from '../../../services/gtfsStations'; import { getTrainLogoAndMeta } from '../../../utils/trainLogoHelper'; // Pictogramme Autocar SNCF / MOBIGO const AutocarIcon = ({ size = '50px', color = '#ffffff', style = {} }) => ( ); // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (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}h${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')}h${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')}h${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')}h${m3[2]}`; } return v.slice(0,5).replace(':','h'); } return String(v).slice(0,5).replace(':','h'); }; const isSubstitution = (h) => { if (!h) return false; if (h.substitution_disponible === true) return true; const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true; const subStr = (typeof h.substitution === 'string' && ['true','1','yes','y','t'].includes(h.substitution.toLowerCase())); const typeMatch = h.type_train && /subst|substitut|remplac/i.test(String(h.type_train)); const commentMatch = h.comment && /subst|substitut|remplac/i.test(String(h.comment)); return Boolean(subFlag || subStr || typeMatch || commentMatch); }; const formatStationName = (name) => { if (!name) return ''; let str = String(name).trim(); str = str.replace(/^Gare de\s+/i, '').replace(/^Gare d'\s*/i, ''); str = str.replace(/([a-zA-ZÀ-ÿ])-(sur|en|les|sous|au|aux)-([a-zA-ZÀ-ÿ])/gi, '$1-$2-$3'); str = str.replace(/-(Viotte|Bercy|Part[- ]Dieu|Perrache|Gare[- ]de[- ]Lyon|Saint[- ]Lazare|Austerlitz|Montparnasse|Est|Nord|Ville|Centre|Rive[- ]Droite|Rive[- ]Gauche|Chantiers|Matabiau|Saint[- ]Jean|Saint[- ]Charles|Flandres|Europe|TGV|Alésia|Saint[- ]Gervais)/gi, ' $1'); str = str.replace(/Lyon-Part-Dieu/gi, 'Lyon Part Dieu'); str = str.replace(/Paris-Gare-de-Lyon/gi, 'Paris Gare de Lyon'); str = str.replace(/Paris-Bercy/gi, 'Paris Bercy'); str = str.replace(/Besançon-Viotte/gi, 'Besançon Viotte'); str = str.replace(/Besancon-Viotte/gi, 'Besançon Viotte'); str = str.replace(/Les-Laumes-Alésia/gi, 'Les Laumes Alésia'); str = str.replace(/Les-Laumes-Alesia/gi, 'Les Laumes Alésia'); str = str.replace(/Auxerre-Saint-Gervais/gi, 'Auxerre Saint-Gervais'); str = str.replace(/Lyon-Perrache/gi, 'Lyon Perrache'); return str; }; const formatPlatform = (p) => { if (!p) return ''; return String(p).toUpperCase().replace(/VOIE|QUAI|\s+/gi, '').trim(); }; const getStopsListForDeparts = (horaire, currentStationName, normalizeFn) => { const stops = (horaire && (horaire.stops || horaire.gares_desservies)) || []; if (!Array.isArray(stops) || stops.length === 0) { const dest = formatStationName((horaire && (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination)) || ''); return dest ? [dest] : []; } const curNorm = normalizeFn ? normalizeFn(currentStationName || '') : String(currentStationName || '').toLowerCase().trim(); let startIdx = 0; if (curNorm) { const foundIdx = stops.findIndex(s => { const sName = (s.nom || s.gare_nom || s.name || s.stop_name || '').toString(); const norm = normalizeFn ? normalizeFn(sName) : sName.toLowerCase().trim(); return norm === curNorm; }); if (foundIdx >= 0) { startIdx = foundIdx + 1; } } const remainingStops = stops.slice(startIdx); if (remainingStops.length === 0) { const dest = formatStationName((horaire && (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination)) || ''); return dest ? [dest] : []; } return remainingStops.map(s => formatStationName(s.nom || s.gare_nom || s.name || s.stop_name || '')).filter(Boolean); }; function BottomClock() { 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}
); } 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 ClassiquesDeparts({ defilement = null } = {}){ // lire le paramètre de requête ?gare=Nom-de-la-gare const params = new URLSearchParams(window.location.search); const gareParam = params.get('gare'); // support pour ?gare_id=123 ou ?gareId=123 const gareIdParam = params.get('gare_id') || params.get('gareId'); // gérer les +, décoder et remplacer les tirets par des espaces const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; // Mode défilement (marquee vertical infini) si ?defilement=true dans l'URL ou passé en prop const defilementMode = defilement !== null ? Boolean(defilement) : (params.get('defilement') === 'true'); // debug flag activable via ?debug=1 or ?debug=true const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true'); const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]); // Alterner toutes les 2 secondes entre type+numéro et statut const [showStatus, setShowStatus] = useState(false); // Afficher la cause de perturbation 5s, cacher 5s (cycle indépendant) const [showInfoCause, setShowInfoCause] = useState(true); // État pour l'heure actuelle, mise à jour chaque seconde pour la logique d'affichage du quai const [currentTime, setCurrentTime] = useState(new Date()); // Met à jour l'heure actuelle chaque seconde useEffect(() => { const id = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(id); }, []); // Données depuis Supabase const [horaires, setHoraires] = useState([]); const [allHoraires, setAllHoraires] = useState([]); const [perturbations, setPerturbations] = useState([]); // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...) const [, setStationType] = useState(null); const [, setTravaux] = useState([]); const [, setLoading] = useState(false); const [, setError] = useState(null); // debug des données brutes renvoyées par Supabase const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] }); const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); useAnnonceSonoreRealtime(resolvedGareId, 'ClassiquesDeparts'); useAutoAnnonce(horaires, currentTime, false); // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent useEffect(() => { let mounted = true; const source = getSourceFromPath(window.location.pathname); const fetchStationType = async () => { if (source === 'sncf') { if (mounted) { setStationType('interurbaine'); 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 => matchStationNameStrict(g.nom, gareDisplay)); const chosen = exactMatch || (gdata.length === 1 ? gdata[0] : null); 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 (définis ici pour éviter use-before-define dans useEffect) --- 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); // some payloads wrap { arrets: [...], jours_perturbation: [...] } if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed.arrets)) return parsed.arrets; // if parsed itself is an array-like object, return it return parsed; } return []; } catch (e) { return []; } }, []); // Retourne la liste d'arrêts d'un horaire, supporte plusieurs noms de champs (stops, gares_desservies...) 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; // parfois la colonne est stockée sous forme de chaîne JSON 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 []; }, []); // parse perturbations_arrets as object to extract both arrets and jours_perturbation 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 }; } }, []); // extrait un set de dates 'YYYY-MM-DD' depuis p.jours_perturbation (top-level) ou depuis perturbations_arrets.jours_perturbation const getPerturbationJours = useCallback((p) => { if (!p) return null; // check top-level field first 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); } } // fallback: inspect perturbations_arrets object 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; }, []); // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant) const parseTimeHM = useCallback((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 formatTimeFromHM = useCallback((totalMinutes) => { if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; const m = ((totalMinutes % 1440) + 1440) % 1440; const hh = String(Math.floor(m / 60)).padStart(2, '0'); const mm = String(m % 60).padStart(2, '0'); return `${hh}h${mm}`; }, []); // buildPerturbMap now accepts an optional list of horaires to link horaire_id -> numero_train 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 we have horairesList, propagate horaire_id perturbations to byNumber using the matching horaire's numero_train 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] || []; // append all perturbations that reference this horaire id (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); } } } } } catch (e) { // ignore mapping errors } } return idx; }, [perturbations, allHoraires, safeParseArrets, normalizeName]); const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => { const res = []; const map = idx || buildPerturbMap(); try { debug('findPerturbsForHoraireAndStop', { horaireId: horaire && (horaire.id || horaire.horaire_id), numero: horaire && (horaire.numero_train || horaire.numero), stationId, stationName, counts: { byHoraire: Object.keys(map.byHoraire||{}).length, byNumber: Object.keys(map.byNumber||{}).length } }); } catch (e) {} 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 (hId != null) { const kh = String(hId); if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); } if (number != null) { const kn = String(number); if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); } if (stationId != null) { const ks = String(stationId); if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); } if (stationName && map.byStopName) { const key = normalizeName(stationName); if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation)); // recherche permissive : accepter les clés qui contiennent ou sont contenues par le needle try { const needle = key; for (const existingKey of Object.keys(map.byStopName || {})) { if (existingKey === needle) continue; if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); } } } catch (e) { // ignore } } return Array.from(new Set(res)); }, [buildPerturbMap, normalizeName, debug]); // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing 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; } if (p.cause_details) { causeDetails = p.cause_details; } } } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; if (maxDelay != null) { const dH = Math.floor(maxDelay / 60); const dRem = maxDelay % 60; const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`; return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails }; } return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { const map = buildPerturbMap(perturbList || []); // fallback to global display station context 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 }); } // compute display time using delay at the display station if any const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); horaire._stopsStatus = stopsStatus; // prefer platform from stop data (quai) if available 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 || ''; return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && matchStationNameStrict(nm, targetStationName)); }) || null; let platformVal = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay || stopForStation.voie)) || horaire.quai || horaire.quai_reel || horaire.platform || horaire.voie || ''; if (!platformVal) { platformVal = resolveNominalTrack(targetStationName || gareDisplay, horaire.numero_train || horaire.num, targetStationId || gareIdParam); } horaire._displayPlatform = String(platformVal || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); return horaire; }); }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, gareParam, getStops, normalizeName]); useEffect(() => { // Alternance toutes les 2 secondes (type+numéro ↔ statut / suppression) const id = setInterval(() => setShowStatus(s => !s), 2000); return () => clearInterval(id); }, []); // Cycle 5s visible / 5s caché pour la cause de perturbation useEffect(() => { let timeoutId; const scheduleCycle = (currentlyVisible) => { timeoutId = setTimeout(() => { setShowInfoCause(!currentlyVisible); scheduleCycle(!currentlyVisible); }, 5000); }; setShowInfoCause(true); scheduleCycle(true); return () => clearTimeout(timeoutId); }, []); // Récupération des données (SNCF GTFS ou Supabase) — avec polling pour synchroniser en continu useEffect(() => { let mounted = true; let inFlight = false; // empêche les fetchs concurrents let pendingRefresh = false; const source = getSourceFromPath(window.location.pathname); const fetchData = async () => { if (!mounted) return; if (inFlight) { pendingRefresh = true; return; } inFlight = true; pendingRefresh = false; if (source === 'sncf') { setLoading(true); setError(null); try { const { data: sncfData, error: sncfErr } = await getSncfTimetable({ gareName: gareDisplay || gareParam, mode: 'departs' }); if (!mounted) { inFlight = false; return; } if (sncfErr) { setError(sncfErr); } else { const formatted = (sncfData || []).map(t => { const depHM = (typeof t._depHM === 'number') ? t._depHM : parseTimeHM(t.heure_depart); const plat = String(t._displayPlatform || t.quai_reel || t.quai || t.voie || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); return { ...t, _displayTime: formatTimeNoSeconds(t.heure_depart), _displayPlatform: plat, _depHM: depHM, _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 } }; }); const maxCount = defilementMode ? 20 : 10; const limited = (formatted || []).slice(0, maxCount); setHoraires(limited); setAllHoraires(limited); } } catch (e) { if (mounted) setError(e.message || String(e)); } finally { if (mounted) setLoading(false); inFlight = false; if (pendingRefresh && mounted) { pendingRefresh = false; fetchData(); } } return; } if (!supabase) { console.warn('Supabase non initialisé — vérifiez REACT_APP_SUPABASE_URL et REACT_APP_SUPABASE_ANON_KEY'); setError('Supabase non initialisé — vérifiez les variables d\'environnement'); setLoading(false); inFlight = false; return; } setLoading(true); setError(null); try { const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ fetchAllRows(supabase.from('horaires').select('*')), supabase.from('perturbations').select('*'), supabase.from('travaux').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; } // debug: afficher ce que Supabase retourne // eslint-disable-next-line no-console debug('Supabase horaires fetched', { count: (horairesData || []).length, sample: (horairesData || []).slice(0,5) }); if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) }); // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) let parsedHoraires = (horairesData || []).map(h => h || {}); // Parser gares_desservies si c'est une chaîne JSON 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) { // ignore parse error } return h; }); // debug: afficher après normalisation // eslint-disable-next-line no-console debug('Supabase horaires normalized', { count: parsedHoraires.length, sampleStopsExample: (parsedHoraires[0] && parsedHoraires[0].stops) || null }); // stocker la liste complète (non filtrée) pour associer horaire_id -> numero_train if (mounted) setAllHoraires(parsedHoraires || []); // Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée // (soit comme gare_depart, soit comme un arrêt intermédiaire dans `stops` / `gares_desservies`) // Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus) let displayHoraires = parsedHoraires.slice(); if (gareDisplay) { displayHoraires = displayHoraires.filter(h => { // Exclure si la gare ciblée est la gare d'arrivée finale const destName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString(); if (matchStationNameStrict(destName, gareDisplay)) return false; // Inclure si gare de départ correspond const departName = (h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString(); if (matchStationNameStrict(departName, gareDisplay)) return true; // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max) 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 = (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 (matchStationNameStrict(stopName, gareDisplay)) 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; }); } // Normaliser date de service (si présente) 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) })); // parse HH:mm or HH:MM:SS into minutes since midnight 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; }; // enrichir withDates avec heures en minutes pour aujourd'hui et demain 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 || ''; // si stops contient la gare ciblée, préférer son 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 => matchStationNameStrict(s.gare_nom || s.nom || s.name || '', 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 = formatTimeNoSeconds(raw); return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; }); // fonctions utilitaires de jours 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) => { if (!h || !d) return false; const pad = (n) => String(n).padStart(2, '0'); const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; const targetDay = d.getDay(); if (Array.isArray(h.dates) && h.dates.length > 0) { return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); } const customDaysRaw = h.jours_personnalises; if (customDaysRaw) { let customDays = []; if (Array.isArray(customDaysRaw)) { customDays = customDaysRaw; } else if (typeof customDaysRaw === 'string') { try { customDays = JSON.parse(customDaysRaw); } catch (e) {} } if (Array.isArray(customDays) && customDays.length > 0) { const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); if (match) return Boolean(match.circule); } } const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; const colName = `circule_${dayNames[targetDay]}`; if (typeof h[colName] !== 'undefined' && h[colName] !== null) { const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; if (db && targetDateHyphen < db) return false; if (df && targetDateHyphen > df) return false; return Boolean(h[colName]); } if (Array.isArray(h.days) && h.days.length > 0) { if (!h.days.includes(targetDay)) return false; if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; return true; } const singleDate = h.date_depart || h.date || h.service_date; if (singleDate) { const sStr = String(singleDate).slice(0, 10); return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; } return true; }; // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* // Calculer les travaux applicables et les substitutions const travauxList = travauxData || []; 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; }; // compute linkage and travaux applicability per horaire const enriched = enrich.map(h => { let linked = false; let appliesToday = false; let appliesTomorrow = false; let linkedTravaux = null; for (const t of travauxList) { // Extraire identifiants de ligne (différentes variantes possibles) 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; // Si le travail a une ligne attribuée, n'appliquer le travail QUE si la ligne correspond // (si hLine est absent on considère qu'on ne peut pas appliquer ce travail à cet horaire) if (typeof tLine !== 'undefined' && tLine !== null) { if (typeof hLine === 'undefined' || hLine === null) { // pas de ligne sur l'horaire -> ignorer ce travail pour cet horaire continue; } if (String(tLine) !== String(hLine)) { // lignes différentes -> ignorer ce travail pour cet horaire continue; } } const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero; let matchRef = ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero)); if (matchRef) linked = true; const appT = travauxApplies(t,h,today); const appY = travauxApplies(t,h,tomorrow); if (appT) appliesToday = true; if (appY) appliesTomorrow = true; if (matchRef || appT || appY) { linkedTravaux = t; } } return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow, _linkedTravauxObj: linkedTravaux }; }); // Build today and tomorrow candidate lists const todayCandidates = enriched.filter(h => { 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 travaux apply at this horaire today, hide regular horaires if (h._travauxAppliesToday && !isSubstitution(h)) return false; if (isSubstitution(h)) { // show substitution only if substitution_disponible AND linked to travaux that apply today return Boolean(h.substitution_disponible === true && h._linkedToTravaux && h._travauxAppliesToday); } return true; }); const tomorrowCandidates = enriched.filter(h => { if (h._date && h._date !== tomorrowStr) return false; if (!circulatesOn(h, tomorrow)) return false; // hide regular horaires if travaux apply tomorrow during their time window if (h._travauxAppliesTomorrow && !isSubstitution(h)) return false; if (isSubstitution(h)) { // show substitution only if substitution_disponible AND linked to travaux that apply tomorrow return Boolean(h.substitution_disponible === true && h._linkedToTravaux && h._travauxAppliesTomorrow); } return true; }); // final list: if there are any todayCandidates use them; otherwise use tomorrowCandidates let finalList; let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); finalList = [...finalList, ...sortedTomorrow]; } serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); serviceDateStr = tomorrowStr; } // Construire un index local des perturbations à partir des données fraîchement récupérées // Filtrer pour ne garder que les perturbations applicables aujourd'hui (éviter d'appliquer les perturbations d'hier) 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); // si pDate fourni et ne correspond pas à la date de service -> ignorer if (pDate && pDate !== serviceDateStr) return false; // si fourche de dates fournie et la date de service n'est pas dans l'intervalle -> ignorer if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; // si jours_perturbation fourni -> vérifier que la date de service en fait partie try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(serviceDateStr)) return false; } catch (e) { // ignore parse errors and keep perturbation } return true; } catch (e) { return true; } }); const localPerturbMap = buildPerturbMap(filteredPerturb || [], parsedHoraires || []); // Debug: afficher sommaire de l'index des perturbations debug('localPerturbMap summary', { horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length, numberCount: Object.keys(localPerturbMap.byNumber || {}).length, stopIdCount: Object.keys(localPerturbMap.byStopId || {}).length, stopNameCount: Object.keys(localPerturbMap.byStopName || {}).length, globalCount: (localPerturbMap.global || []).length, sampleStopNames: Object.keys(localPerturbMap.byStopName || {}).slice(0,10) }); // Annoter chaque horaire avec un statut calculé (synchronisé avec la BDD) // déterminer la date de service utilisée pour ces finalList (aujourd'hui ou demain) const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr); // Debug: log first few annotated horaires try { debug('annotated horaires sample', annotated.slice(0,6).map(a => ({ id: a.id, numero: a.numero_train || a.numero, _status: a._status, _displayTime: a._displayTime }))); } catch (e) {} // 10 lignes en mode normal, 20 en mode défilement setHoraires((annotated || []).slice(0, defilementMode ? 20 : 10)); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); } catch (err) { // eslint-disable-next-line no-console console.error('Erreur Supabase horaires', err); if (mounted) setError(err.message || String(err)); } finally { if (mounted) setLoading(false); inFlight = false; } }; // Exécuter immédiatement, puis périodiquement pour synchroniser sans recharger fetchData(); const intervalMs = 15000; // 15 secondes par défaut const intervalId = setInterval(fetchData, intervalMs); let rtDebounce = null; const onRtUpdate = () => { if (!mounted) return; clearTimeout(rtDebounce); rtDebounce = setTimeout(() => { if (mounted) fetchData(); }, 2000); }; if (typeof window !== 'undefined') { window.addEventListener('sncf-realtime-updated', onRtUpdate); } return () => { mounted = false; clearInterval(intervalId); clearTimeout(rtDebounce); if (typeof window !== 'undefined') { window.removeEventListener('sncf-realtime-updated', onRtUpdate); } }; }, [gareDisplay, gareParam, gareIdParam, defilementMode]); // Helpers pour perturbations (memoized plus haut) // Création des lignes vides pour créer l'espace const emptyRows = Array.from({ length: 10 }).map((_, i) => ({ _isEmpty: true, id: `empty-${i}` })); const maxRowsCount = defilementMode ? 20 : 10; const limitedHoraires = (horaires || []).slice(0, maxRowsCount); // Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie) const rowsToRender = defilementMode ? [...limitedHoraires, ...emptyRows, ...limitedHoraires, ...emptyRows] : limitedHoraires; // Calcul dynamique de l'animation CSS useEffect(() => { if (defilementMode && horaires.length > 0) { // Nombre de lignes de données const dataRows = horaires.length; // 10 lignes vides const emptyRowsCount = 10; // Temps pour défiler les données (2s par ligne au lieu de 1s) const dataScrollTime = dataRows * 2; // Temps pour défiler l'espace vide (2s par ligne) const emptyScrollTime = emptyRowsCount * 2; // Temps de pause de 10 secondes const pauseTime = 10; // Durée totale d'un cycle (défilement + pause) const totalAnimationTime = dataScrollTime + emptyScrollTime + pauseTime; // Calcul des pourcentages pour l'animation // On commence à 0 // À la fin du défilement des données, on a défilé de (dataRows / (totalCycleRows * 2)) * 100% de la hauteur totale // À la fin du défilement de l'espace, on a défilé de (totalCycleRows / (totalCycleRows * 2)) * 100% (soit 50%) // Ensuite on fait une pause pendant 10 secondes (donc le % de temps avance, mais pas la translation) const scrollEndPercent = ((dataScrollTime + emptyScrollTime) / totalAnimationTime) * 100; const styleSheet = document.createElement("style"); styleSheet.innerText = ` @keyframes customScrollMarquee { 0% { transform: translateY(0); } ${scrollEndPercent}% { transform: translateY(-50%); } 100% { transform: translateY(-50%); } } .aff-classiques-departs__scroll-track { animation: customScrollMarquee ${totalAnimationTime}s linear infinite !important; } `; document.head.appendChild(styleSheet); return () => { document.head.removeChild(styleSheet); }; } }, [defilementMode, horaires]); const renderRow = (h, idx) => { const isExtended = Boolean(h && h._isExtended); const trainIndex = (h && typeof h._trainIndex === 'number') ? h._trainIndex : idx; const isOdd = (trainIndex % 2 === 0); if (h && h._isEmpty) { if (isExtended) { return (
  • ); } return
  • ; } const horaire = h || {}; const { logoSrc, typeLabel, numTrain, isCar: metaIsCar, isSubstitution: metaIsSubstitution, brand: metaBrand, brandClass } = getTrainLogoAndMeta(horaire, gareDisplay || gareParam); const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.departure_time || horaire.time || horaire.heure || ''); const destination = horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || ''; const formattedDestination = formatStationName(destination); const rawPlatform = ( horaire._displayPlatform || horaire.quai || horaire.voie || horaire.platform || horaire.platform_code || horaire.track || (horaire.stop_point && horaire.stop_point.platform_code) || '' ).toString().trim(); const cleanPlatform = formatPlatform(rawPlatform); const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); const stationName = gareDisplay || horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null; const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); const isSuppressed = status && status.label === 'supprimé'; const isDelayed = status && status.label && String(status.label).startsWith('retardé'); const isCar = metaIsCar || ( (horaire.type_train && (horaire.type_train.toUpperCase().includes('AUTOCAR') || horaire.type_train.toUpperCase().includes('CAR TER') || horaire.type_train.toUpperCase() === 'BUS')) || (horaire.op && (horaire.op.toUpperCase().includes('AUTOCAR') || horaire.op.toUpperCase().includes('CAR TER') || horaire.op.toUpperCase() === 'BUS')) || (horaire.type && (horaire.type.toUpperCase().includes('AUTOCAR') || horaire.type.toUpperCase().includes('CAR TER') || horaire.type.toUpperCase() === 'BUS')) || Boolean(horaire.is_substitution) || (numTrain && String(numTrain).startsWith('3') && String(numTrain).length >= 5) || typeLabel === 'AUTOCAR' || rawPlatform === 'AUTOCAR' || rawPlatform === 'CAR' || rawPlatform === 'BUS' || cleanPlatform === 'AUTOCAR' || cleanPlatform === 'CAR' || cleanPlatform === 'BUS' ); const isSub = metaIsSubstitution || Boolean(horaire.is_substitution); const showPlatform = !isSuppressed && (isCar || Boolean(cleanPlatform)); const stopsList = isExtended ? getStopsListForDeparts(horaire, gareDisplay || gareParam, normalizeName) : []; if (isExtended) { return (
  • {/* Ligne haute (108px) : infos complètes */}
    {typeLabel}
    {showStatus ? ( isSuppressed ? (
    supprimé
    ) : isDelayed ? (
    retardé
    +{status.delayMinutes || horaire.retard_minutes || 0} min.
    ) : (
    à l'heure
    ) ) : ( <>
    {typeLabel}
    {numTrain}
    )}
    {time}
    {showPlatform ? (
    {isCar ? ( ) : (
    {cleanPlatform}
    )}
    ) : null}
    {/* Ligne basse (108px) : gares desservies défilantes avec puces jaunes */}
    {isSub && ( CAR DE REMPLACEMENT • )}{stopsList.map((stopName, sIdx) => ( {sIdx > 0 && •} {stopName} ))} } />
  • ); } return (
  • {/* COL 1 : Logo opérateur */}
    {typeLabel}
    {/* COL 2 : Type + Numéro OU Statut */}
    {showStatus ? ( isSuppressed ? (
    supprimé
    ) : isDelayed ? (
    retardé
    +{status.delayMinutes || horaire.retard_minutes || 0} min.
    ) : (
    à l'heure
    ) ) : ( <>
    {typeLabel}
    {numTrain}
    )}
    {/* COL 3 : Heure (grande, jaune) */}
    {time}
    {/* COL 4 : Destination */}
    {/* COL 5 : Quai / Voie */}
    {showPlatform ? (
    {isCar ? ( ) : (
    {cleanPlatform}
    )}
    ) : null}
  • ); }; // Gestion des paramètres extended et time const queryParams = new URLSearchParams(window.location.search || ''); const extendedParam = parseInt(queryParams.get('extended') || queryParams.get('extendedLines') || '0', 10); const isTimeEnabled = queryParams.get('time') === 'true' || queryParams.get('time') === '1'; const maxSlots = isTimeEnabled ? 9 : 10; const extendedCount = Math.min(Math.max(0, extendedParam), 3); const displayRows = []; let trainIdx = 0; let usedSlots = 0; for (let i = 0; i < extendedCount && usedSlots + 2 <= maxSlots; i++) { const train = (horaires && horaires[trainIdx]) || { _isEmpty: true }; displayRows.push({ ...train, _isExtended: true, _trainIndex: trainIdx }); usedSlots += 2; trainIdx++; } while (usedSlots < maxSlots) { const train = (horaires && horaires[trainIdx]) || { _isEmpty: true }; displayRows.push({ ...train, _isExtended: false, _trainIndex: trainIdx }); usedSlots += 1; trainIdx++; } const infoMessage = (perturbations && perturbations.length > 0) ? perturbations.map(p => p.titre ? `${p.titre} : ${p.message || p.description || ''}` : p.message || '').join(' • ') : ''; return (
    {/* Watermark vertical "départs" sur la droite */}
    départs
    {defilementMode ? ( /* ── MODE DÉFILEMENT : marquee vertical infini ── */
    {horaires.length === 0 ? ( ) : ( )}
    ) : ( /* ── MODE NORMAL : 9 ou 10 lignes ── */ )}
    ) }