Files
ferrovia-infogare/src/pages/afficheurs/classiques/ClassiquesDeparts.js
T
mathis d8f8ceaa9f RELEASE V5.6.0 : Voici les modifications apportées :
-> REFONTE TOTALE DE TOUS LES AFFICHEURS CLASSIQUES + QUAIS + OPTION TGV

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

-> ADAPTABILITÉ EN FONCTION DES SITUATIONS
2026-08-31 17:32:58 +02:00

1516 lines
63 KiB
JavaScript

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 = {} }) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill={color}
style={{ flexShrink: 0, ...style }}
>
<path d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z" />
</svg>
);
// 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 (
<div className="aff-classiques-departs__bottom-clock" aria-hidden>
<span className="aff-classiques-departs__bottom-clock-hm">{hh}</span>
<span className="aff-classiques-departs__bottom-clock-hm">{mm}</span>
<span className="aff-classiques-departs__bottom-clock-s">{ss}</span>
</div>
);
}
function Clock(){
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return (
<div className="aff-classiques-departs__clock-container" aria-hidden>
<div className="aff-classiques-departs__clock-box">
<div className="aff-classiques-departs__clock-main">{hh}:{mm}</div>
<div className="aff-classiques-departs__clock-seconds">{ss}</div>
</div>
</div>
);
}
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) return { label: `retardé +${maxDelay}min.`, 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
const source = getSourceFromPath(window.location.pathname);
const fetchData = async () => {
if (!mounted) return;
if (inFlight) return;
inFlight = true;
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;
}
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);
return () => { mounted = false; clearInterval(intervalId); };
}, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours, 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 (
<li
className={`aff-classiques-departs__row aff-classiques-departs__row--extended ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`}
key={`empty-${idx}`}
style={{ opacity: 0 }}
>
<div className="aff-classiques-departs__extended-top"></div>
<div className="aff-classiques-departs__extended-bottom"></div>
</li>
);
}
return <li className={`aff-classiques-departs__row ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`} key={`empty-${idx}`} style={{ opacity: 0 }}></li>;
}
const horaire = h || {};
const { logoSrc, typeLabel, numTrain } = getTrainLogoAndMeta(horaire);
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 = (
(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 showPlatform = !isSuppressed && (isCar || Boolean(cleanPlatform));
const stopsList = isExtended ? getStopsListForDeparts(horaire, gareDisplay || gareParam, normalizeName) : [];
if (isExtended) {
return (
<li
className={`aff-classiques-departs__row aff-classiques-departs__row--extended ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`}
key={idx}
>
{/* Ligne haute (108px) : infos complètes */}
<div className="aff-classiques-departs__extended-top">
<div className="aff-classiques-departs__col-logo">
<img src={logoSrc} alt={typeLabel} className="aff-classiques-departs__op-logo" />
</div>
<div className="aff-classiques-departs__col-meta">
{showStatus ? (
isSuppressed ? (
<div className="aff-classiques-departs__status-suppressed">supprimé</div>
) : isDelayed ? (
<div className="aff-classiques-departs__status-delayed">
<div className="aff-classiques-departs__status-delayed-label">retardé</div>
<div className="aff-classiques-departs__status-delayed-time">+{status.delayMinutes || horaire.retard_minutes || 0} min.</div>
</div>
) : (
<div className="aff-classiques-departs__status-ontime">à l'heure</div>
)
) : (
<>
<div className="aff-classiques-departs__train-type">{typeLabel}</div>
<div className="aff-classiques-departs__train-number">{numTrain}</div>
</>
)}
</div>
<div className="aff-classiques-departs__col-time">
<div
className="aff-classiques-departs__time"
style={{ visibility: isSuppressed && showStatus ? 'hidden' : 'visible' }}
>
{time}
</div>
</div>
<div className="aff-classiques-departs__col-destination">
<MarqueeText text={formattedDestination} className="aff-classiques-departs__destination" />
</div>
<div className="aff-classiques-departs__col-platform">
{showPlatform ? (
<div className="aff-classiques-departs__platform-box">
{isCar ? (
<AutocarIcon size="48px" />
) : (
<div className="aff-classiques-departs__platform-text">{cleanPlatform}</div>
)}
</div>
) : null}
</div>
</div>
{/* Ligne basse (108px) : gares desservies défilantes avec puces jaunes */}
<div className="aff-classiques-departs__extended-bottom">
<div className="aff-classiques-departs__sub-stops">
<MarqueeText
className="aff-classiques-departs__sub-stops-marquee"
speed={50}
text={
<span className="aff-classiques-departs__stops-chain">
{stopsList.map((stopName, sIdx) => (
<span key={sIdx} className="aff-classiques-departs__stop-item">
{sIdx > 0 && <span className="aff-classiques-departs__stop-bullet">•</span>}
<span className="aff-classiques-departs__stop-name">{stopName}</span>
</span>
))}
</span>
}
/>
</div>
</div>
</li>
);
}
return (
<li className={`aff-classiques-departs__row ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`} key={idx}>
{/* COL 1 : Logo opérateur */}
<div className="aff-classiques-departs__col-logo">
<img src={logoSrc} alt={typeLabel} className="aff-classiques-departs__op-logo" />
</div>
{/* COL 2 : Type + Numéro OU Statut */}
<div className="aff-classiques-departs__col-meta">
{showStatus ? (
isSuppressed ? (
<div className="aff-classiques-departs__status-suppressed">supprimé</div>
) : isDelayed ? (
<div className="aff-classiques-departs__status-delayed">
<div className="aff-classiques-departs__status-delayed-label">retardé</div>
<div className="aff-classiques-departs__status-delayed-time">+{status.delayMinutes || horaire.retard_minutes || 0} min.</div>
</div>
) : (
<div className="aff-classiques-departs__status-ontime">à l'heure</div>
)
) : (
<>
<div className="aff-classiques-departs__train-type">{typeLabel}</div>
<div className="aff-classiques-departs__train-number">{numTrain}</div>
</>
)}
</div>
{/* COL 3 : Heure (grande, jaune) */}
<div className="aff-classiques-departs__col-time">
<div
className="aff-classiques-departs__time"
style={{ visibility: isSuppressed && showStatus ? 'hidden' : 'visible' }}
>
{time}
</div>
</div>
{/* COL 4 : Destination */}
<div className="aff-classiques-departs__col-destination">
<MarqueeText text={formattedDestination} className="aff-classiques-departs__destination" />
</div>
{/* COL 5 : Quai / Voie */}
<div className="aff-classiques-departs__col-platform">
{showPlatform ? (
<div className="aff-classiques-departs__platform-box">
{isCar ? (
<AutocarIcon size="48px" />
) : (
<div className="aff-classiques-departs__platform-text">{cleanPlatform}</div>
)}
</div>
) : null}
</div>
</li>
);
};
// 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 (
<div className="aff-classiques-departs__container">
{/* Watermark vertical "départs" sur la droite */}
<div className="aff-classiques-departs__sidebar" aria-hidden>
<div className="aff-classiques-departs__sidebar-text">départs</div>
</div>
{defilementMode ? (
/* ── MODE DÉFILEMENT : marquee vertical infini ── */
<div className="aff-classiques-departs__scroll-container">
{horaires.length === 0 ? (
<ul className="aff-classiques-departs__list">
{displayRows.map((h, idx) => renderRow(h, idx))}
</ul>
) : (
<ul
className="aff-classiques-departs__scroll-track"
>
{rowsToRender.map((h, idx) => renderRow(h, idx))}
</ul>
)}
</div>
) : (
/* ── MODE NORMAL : 9 ou 10 lignes ── */
<ul className="aff-classiques-departs__list">
{displayRows.map((h, idx) => renderRow(h, idx))}
{isTimeEnabled && (
<li className="aff-classiques-departs__row aff-classiques-departs__row--info-bottom">
<div className="aff-classiques-departs__info-bar">
{infoMessage ? (
<MarqueeText text={infoMessage} className="aff-classiques-departs__info-bar-text" />
) : null}
</div>
<BottomClock />
</li>
)}
</ul>
)}
</div>
)
}