-> REFONTE DU DESIGN DES AFFICHEURS CLASSIQUES (GÉNÉRAL + QUAIS) -> IMPLÉMENTATION DU DESIGN AFL : GÉNÉRAL ET QUAIS -> CHOIX DE LA SÉLECTION DES TYPES D'AFFICHEURS (GÉNÉRAL / QUAIS) UNE FOIS LE TYPE D'AFFICHEUR SÉLECTIONNÉS EN COURS DE DÉVELOPPEMENT : -> IMPLÉMENTATION DES PERTURBATION DU LANDEMAIN POUR LES HORAIRES DU LANDEMAIN AFFICHÉS AVANT MINUIT. -> IMPLÉMENTATION DES AFFICHEURS D'INFORMATIONS / INFOS TRAVAUX, etc... (AFFICHEURS AFL)
1273 lines
53 KiB
JavaScript
1273 lines
53 KiB
JavaScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import './ClassiquesArrivees.css';
|
|
import supabase from '../../../lib/supabase';
|
|
import Logo from '../../../components/Logo';
|
|
import MarqueeText from '../../../components/MarqueeText';
|
|
|
|
// 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()).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) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;
|
|
const m2 = v.match(/T(\d{2}):(\d{2})/);
|
|
if (m2) return `${m2[1]}h${m2[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);
|
|
};
|
|
|
|
// (La fonction TrainType a été retirée car la colonne affiche désormais directement les
|
|
// informations via .train-meta dans la colonne .col-train-type)
|
|
|
|
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-arrivees__clock-container" aria-hidden>
|
|
<div className="aff-classiques-arrivees__clock-box">
|
|
<div className="aff-classiques-arrivees__clock-main">{hh}:{mm}</div>
|
|
<div className="aff-classiques-arrivees__clock-seconds">{ss}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ClassiquesArrivees(){
|
|
// 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
|
|
const defilementMode = 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 [stationType, 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: [] });
|
|
|
|
// Récupérer le type de gare une seule fois au montage ou quand les paramètres changent
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
const fetchStationType = async () => {
|
|
try {
|
|
let fetchedType = 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;
|
|
} else if (gareDisplay) {
|
|
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
|
|
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle);
|
|
if (!gerr && gdata && gdata.length) {
|
|
const exactMatch = gdata.find(g => {
|
|
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
|
|
const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
|
|
return n1 === n2;
|
|
});
|
|
fetchedType = exactMatch ? exactMatch.type : gdata[0].type;
|
|
}
|
|
}
|
|
if (mounted) {
|
|
setStationType(fetchedType ? String(fetchedType).toLowerCase() : null);
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
if (supabase) {
|
|
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;
|
|
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;
|
|
}, []);
|
|
|
|
// retourne le threshold (en minutes) pour afficher le quai selon le type de gare
|
|
const thresholdForStationType = useCallback((typeGare) => {
|
|
const tG = String(typeGare || '').trim().toLowerCase();
|
|
|
|
// Vérification du type de gare
|
|
if (tG === 'interurbaine' || tG.includes('interurb')) {
|
|
return 360; // 6 heures
|
|
}
|
|
if (tG === 'ville' || tG.includes('ville')) {
|
|
return 30; // 30 minutes
|
|
}
|
|
|
|
// par défaut, 30 minutes
|
|
return 30;
|
|
}, []);
|
|
|
|
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_arrivee || 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 || '';
|
|
const ns = normalizeName(nm || '');
|
|
const target = normalizeName(targetStationName || '');
|
|
return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target);
|
|
}) || null;
|
|
horaire._status = displayStatus;
|
|
horaire._displayTime = displayTime;
|
|
// utiliser quai en priorité
|
|
horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay)) || horaire.quai || horaire.quai_reel || horaire.platform || '';
|
|
return horaire;
|
|
});
|
|
}, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]);
|
|
|
|
useEffect(() => {
|
|
// Alternance toutes les 4 secondes (type+numéro ↔ statut)
|
|
const id = setInterval(() => setShowStatus(s => !s), 4000);
|
|
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 Supabase — maintenant avec polling pour synchroniser sans recharger la page
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
let inFlight = false; // empêche les fetchs concurrents
|
|
|
|
const fetchData = async () => {
|
|
if (!mounted) return;
|
|
if (inFlight) return; // éviter les appels simultanés si le fetch précédent n'est pas terminé
|
|
inFlight = true;
|
|
if (!supabase) {
|
|
// Supabase non initialisé — avertir et définir un message d'erreur pour l'UI
|
|
// eslint-disable-next-line no-console
|
|
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([
|
|
// DEBUG: récupérer explicitement l'id et toutes les colonnes pour s'assurer que l'identifiant
|
|
// `id` est bien présent et peut être mis en correspondance avec `horaire_id` dans perturbations
|
|
supabase.from('horaires').select('id, *'),
|
|
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 || {});
|
|
|
|
// Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD;
|
|
// essayer de parser pour garantir que stops est un tableau utilisable.
|
|
parsedHoraires = parsedHoraires.map(h => {
|
|
try {
|
|
if (h && typeof h.stops === 'string') {
|
|
const parsed = JSON.parse(h.stops);
|
|
if (Array.isArray(parsed)) {
|
|
return { ...h, stops: 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) {
|
|
const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));
|
|
displayHoraires = displayHoraires.filter(h => {
|
|
// Exclure si la gare ciblée est la gare d'arrivée finale
|
|
const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());
|
|
if (destName === normalized) return false;
|
|
|
|
// Inclure si gare de départ correspond
|
|
const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString());
|
|
if (departName === normalized) return true;
|
|
|
|
// Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (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 = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString());
|
|
if (!stopName) continue;
|
|
const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
|
|
if (isTerminus) continue;
|
|
if (stopName === normalized) return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
} else if (gareIdParam) {
|
|
const gid = String(gareIdParam);
|
|
displayHoraires = displayHoraires.filter(h => {
|
|
// Exclure si gare d'arrivée finale correspond
|
|
const destId = (h.gare_arrivee_id || h.gare_arrivee || null);
|
|
if (destId != null && String(destId) === gid) return false;
|
|
|
|
// Inclure si gare de départ correspond
|
|
const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;
|
|
if (departId != null && String(departId) === gid) return true;
|
|
|
|
// Chercher dans les arrêts desservis (ignorer terminus)
|
|
const stopsList = getStops(h);
|
|
if (Array.isArray(stopsList) && stopsList.length) {
|
|
let maxIdx = null;
|
|
for (const st of stopsList) {
|
|
if (typeof st.index !== 'undefined' && st.index !== null) {
|
|
const n = Number(st.index);
|
|
if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);
|
|
}
|
|
}
|
|
for (const s of stopsList) {
|
|
const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
|
|
if (stopId == null) continue;
|
|
const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
|
|
if (isTerminus) continue;
|
|
if (String(stopId) === gid) return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// 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_arrivee || 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 => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null;
|
|
const stop = byId || byName;
|
|
if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure;
|
|
}
|
|
const depHM = parseTimeHM(raw);
|
|
const displayTime = formatTimeNoSeconds(raw);
|
|
return { ...h, _rawTime: raw, _arrHM: 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 weekdayIndex = (d) => d.getDay(); // 0 dimanche .. 6 samedi
|
|
const weekdayNameForColumn = (d) => {
|
|
const i = weekdayIndex(d);
|
|
switch(i) {
|
|
case 1: return 'circule_lundi';
|
|
case 2: return 'circule_mardi';
|
|
case 3: return 'circule_mercredi';
|
|
case 4: return 'circule_jeudi';
|
|
case 5: return 'circule_vendredi';
|
|
case 6: return 'circule_samedi';
|
|
case 0: return 'circule_dimanche';
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
const isPublicHoliday = (d) => {
|
|
const y = d.getFullYear();
|
|
const m = d.getMonth() + 1;
|
|
const day = d.getDate();
|
|
const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
const holidays = [
|
|
`${y}-01-01`, // Jour de l'an
|
|
`${y}-05-01`, // Fête du Travail
|
|
`${y}-05-08`, // Victoire 1945
|
|
`${y}-07-14`, // Fête nationale
|
|
`${y}-08-15`, // Assomption
|
|
`${y}-11-01`, // Toussaint
|
|
`${y}-11-11`, // Armistice 1918
|
|
`${y}-12-25`, // Noël
|
|
];
|
|
// Dates variables pour 2026
|
|
if (y === 2026) {
|
|
holidays.push('2026-04-06'); // Lundi de Pâques
|
|
holidays.push('2026-05-14'); // Ascension
|
|
holidays.push('2026-05-25'); // Lundi de Pentecôte
|
|
}
|
|
return holidays.includes(s);
|
|
};
|
|
|
|
const parseCirculationFlag = (value, defaultValue) => {
|
|
if (value === null || typeof value === 'undefined') {
|
|
return defaultValue;
|
|
}
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return value === 1;
|
|
}
|
|
if (typeof value === 'string') {
|
|
const s = value.trim().toLowerCase();
|
|
if (['true', '1', 't', 'y', 'yes', 'oui'].includes(s)) {
|
|
return true;
|
|
}
|
|
if (['false', '0', 'f', 'n', 'no', 'non'].includes(s)) {
|
|
return false;
|
|
}
|
|
}
|
|
return defaultValue;
|
|
};
|
|
|
|
const circulatesOn = (h, d) => {
|
|
if (isPublicHoliday(d)) {
|
|
// On public holidays, circulation is determined SOLELY by `circule_jours_feries`.
|
|
// If the flag is true, it runs. Otherwise, it doesn't.
|
|
return parseCirculationFlag(h['circule_jours_feries'], false);
|
|
}
|
|
|
|
// On a regular day, check the corresponding weekday column.
|
|
const col = weekdayNameForColumn(d);
|
|
if (!col) return true; // Should not happen, but permissive.
|
|
|
|
// For regular weekdays, the default is to run unless specified otherwise.
|
|
return parseCirculationFlag(h[col], 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._arrHM === 'number') return h._arrHM >= startHM && h._arrHM <= 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._arrHM !== 'number') return false;
|
|
if (h._arrHM < 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._arrHM||99999)-(b._arrHM||99999));
|
|
serviceDateStr = todayStr;
|
|
} else {
|
|
finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||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, gareIdParam, 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}` }));
|
|
|
|
// Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie)
|
|
const rowsToRender = defilementMode
|
|
? [...horaires, ...emptyRows, ...horaires, ...emptyRows]
|
|
: horaires;
|
|
|
|
// 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-arrivees__scroll-track {
|
|
animation: customScrollMarquee ${totalAnimationTime}s linear infinite !important;
|
|
}
|
|
`;
|
|
document.head.appendChild(styleSheet);
|
|
|
|
return () => {
|
|
document.head.removeChild(styleSheet);
|
|
};
|
|
}
|
|
}, [defilementMode, horaires]);
|
|
|
|
|
|
const renderRow = (h, idx) => {
|
|
if (h._isEmpty) {
|
|
return <li className="aff-classiques-arrivees__row" key={`empty-${idx}`} style={{ background: 'transparent' }}></li>;
|
|
}
|
|
|
|
const horaire = h || {};
|
|
const op = horaire.type_train || horaire.op || '';
|
|
const isCarTER = (op === 'Car TER' || op === 'Autocar');
|
|
const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || '');
|
|
const origin = horaire.gare_depart_nom || '';
|
|
const platform = (horaire._displayPlatform || '').toString().trim();
|
|
const stationId = horaire.gare_arrivee_id || horaire.gare_id || horaire.destination_id || (gareIdParam || null);
|
|
const stationName = gareDisplay || horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination_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é');
|
|
|
|
let showPlatform = !isSuppressed && Boolean(platform);
|
|
if (showPlatform) {
|
|
const nowHM = currentTime.getHours()*60 + currentTime.getMinutes();
|
|
const sched = (typeof horaire._arrHM === 'number') ? horaire._arrHM : parseTimeHM(horaire.heure_arrivee || horaire.time || horaire.heure || horaire._rawTime || '');
|
|
const threshold = thresholdForStationType(stationType);
|
|
if (typeof sched === 'number') {
|
|
let diff = sched - nowHM;
|
|
// Gérer le passage à minuit
|
|
if (diff < -720) { // e.g., now 23:00, sched 01:00, diff is ~ -22 hours
|
|
diff += 1440; // treat as next day
|
|
}
|
|
showPlatform = (diff >= 0 && diff <= threshold);
|
|
} else {
|
|
showPlatform = false;
|
|
}
|
|
}
|
|
|
|
const isSub = isSubstitution(horaire);
|
|
const infoText = isSub
|
|
? (horaire.motif_substitution && horaire.motif_substitution !== 'TRAVAUX' && horaire.motif_substitution !== 'Travaux.' ? horaire.motif_substitution : 'Autocar cour de la gare Plateforme 14')
|
|
: (status && status.causeDetails ? status.causeDetails : '');
|
|
const trainNumber = horaire.numero_train || horaire.numero || horaire.number || '';
|
|
|
|
return (
|
|
<li className="aff-classiques-arrivees__row" key={idx}>
|
|
|
|
{/* COL 1 : Logo opérateur */}
|
|
<div className="aff-classiques-arrivees__col-logo">
|
|
{isCarTER ? (
|
|
<div className="aff-classiques-arrivees__logo-box">
|
|
<img src="/type/sncf-voyageurs-logo.png" alt="SNCF Voyageurs" className="aff-classiques-arrivees__op-logo" />
|
|
</div>
|
|
) : (
|
|
<Logo
|
|
op={op}
|
|
classNameBox="aff-classiques-arrivees__logo-box"
|
|
classNameImg="aff-classiques-arrivees__op-logo"
|
|
classNameText="aff-classiques-arrivees__logo-text"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* COL 2 : Type + Numéro (4s) OU Statut (4s) */}
|
|
<div className="aff-classiques-arrivees__col-meta">
|
|
{showStatus ? (
|
|
isSuppressed ? (
|
|
<div className="aff-classiques-arrivees__status-suppressed">supprimé</div>
|
|
) : isDelayed ? (
|
|
<div className="aff-classiques-arrivees__status-delayed">
|
|
<div className="aff-classiques-arrivees__status-label">retard</div>
|
|
<div className="aff-classiques-arrivees__status-value">{status.delayMinutes} min.</div>
|
|
</div>
|
|
) : (
|
|
<div className="aff-classiques-arrivees__status-ontime">à l'heure</div>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className="aff-classiques-arrivees__train-type">{op}</div>
|
|
<div className="aff-classiques-arrivees__train-number">{trainNumber}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* COL 3 : Heure (grande, jaune) */}
|
|
<div className="aff-classiques-arrivees__col-time">
|
|
<div className="aff-classiques-arrivees__time">{time}</div>
|
|
</div>
|
|
|
|
{/* COL 4 : Destination + Info cause (slide-in 5s/5s) */}
|
|
<div className="aff-classiques-arrivees__col-origin">
|
|
<MarqueeText text={origin} className="aff-classiques-arrivees__destination" />
|
|
{showInfoCause && infoText ? (
|
|
<div className="aff-classiques-arrivees__info-wrapper">
|
|
<div className="aff-classiques-arrivees__info-inner">
|
|
<div className="aff-classiques-arrivees__separator">|</div>
|
|
<div className="aff-classiques-arrivees__info-text">{infoText}</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* COL 5 : Quai OU icône bus */}
|
|
<div className="aff-classiques-arrivees__col-platform">
|
|
{isSub || isCarTER ? (
|
|
<img src="/type/track-car.svg" alt="Car" style={{ width: '6.5vh', height: '6.5vh' }} />
|
|
) : showPlatform ? (
|
|
<div className="aff-classiques-arrivees__platform-box">
|
|
<div className="aff-classiques-arrivees__platform-text">{platform}</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
</li>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="aff-classiques-arrivees__container">
|
|
|
|
{/* Watermark "départs" — diagonal droite */}
|
|
<div className="aff-classiques-arrivees__watermark">arrivées</div>
|
|
|
|
{defilementMode ? (
|
|
/* ── MODE DÉFILEMENT : marquee vertical infini ── */
|
|
<div className="aff-classiques-arrivees__scroll-container">
|
|
{horaires.length === 0 ? (
|
|
<ul className="aff-classiques-arrivees__list">
|
|
<li className="aff-classiques-arrivees__row aff-classiques-arrivees__row--empty">
|
|
<div>Aucun horaire disponible.</div>
|
|
</li>
|
|
</ul>
|
|
) : (
|
|
<ul
|
|
className="aff-classiques-arrivees__scroll-track"
|
|
>
|
|
{rowsToRender.map((h, idx) => renderRow(h, idx))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* ── MODE NORMAL : 10 lignes statiques ── */
|
|
<ul className="aff-classiques-arrivees__list">
|
|
{horaires.length === 0 ? (
|
|
<li className="aff-classiques-arrivees__row aff-classiques-arrivees__row--empty" key="empty">
|
|
<div>Aucun horaire disponible.</div>
|
|
<div style={{fontSize:'0.8rem', opacity:0.8}}>lignes brutes renvoyées : {rawDebug.count}</div>
|
|
{rawDebug.count > 0 && rawDebug.sample && rawDebug.sample.length > 0 ? (
|
|
<pre style={{maxHeight:160, overflow:'auto', fontSize:'0.75rem', marginTop:8, background:'#111', color:'#fff', padding:8}}>
|
|
{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).slice(0,1000)}
|
|
{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).length > 1000 ? '…(truncated)' : ''}
|
|
</pre>
|
|
) : null}
|
|
</li>
|
|
) : horaires.map((h, idx) => renderRow(h, idx))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Horloge — superposée en bas à droite */}
|
|
<div className="aff-classiques-arrivees__footer" aria-hidden>
|
|
<Clock />
|
|
</div>
|
|
|
|
</div>
|
|
)
|
|
} |