53 lines
346 KiB
XML
53 lines
346 KiB
XML
<?xml version="1.0" encoding="UTF-8"?>
|
|
<project version="4">
|
|
<component name="CopilotDiffPersistence">
|
|
<option name="pendingDiffs">
|
|
<map>
|
|
<entry key="/ClassiquesDepartsQuais (snapshot).css">
|
|
<value>
|
|
<PendingDiffInfo>
|
|
<option name="filePath" value="/ClassiquesDepartsQuais (snapshot).css" />
|
|
<option name="originalContent" value=" /* Partie gauche strictement comme l'image */ .aff-quais__left--strict { width: 100%; max-width: 480px; min-width: 340px; background: #fff; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; padding: 32px 32px 24px 32px; box-sizing: border-box; position: relative; height: 100vh; overflow: hidden; } .aff-quais__sncf-logo--strict { width: 54px; height: auto; margin-bottom: 18px; margin-left: 0; margin-top: 0; } .aff-quais__watermark { position: absolute; right: 18px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 100px; color: rgba(23,55,113,0.07); font-weight: 800; letter-spacing: 6px; pointer-events: none; user-select: none; z-index: 0; } .aff-quais__row--time-status { display: flex; align-items: flex-end; gap: 18px; margin-bottom: 18px; z-index: 1; } .aff-quais__time--strict { color: #0d4a87; font-weight: 800; font-size: 56px; line-height: 1; letter-spacing: 0.5px; margin-right: 12px; } .aff-quais__time-status--strict { color: #0d4a87; font-size: 22px; font-weight: 400; opacity: 1; align-self: flex-end; margin-bottom: 7px; } .aff-quais__destination--strict { margin-top: 10px; margin-bottom: 0; color: #0d4a87; z-index: 1; } .aff-quais__destination-line1--strict { font-size: 38px; font-weight: 800; line-height: 1.1; } .aff-quais__destination-line2--strict { font-size: 38px; font-weight: 800; line-height: 1.1; } .aff-quais__train-id--strict { margin-top: 12px; color: #173771; font-weight: 700; font-size: 22px; letter-spacing: 0.5px; z-index: 1; } .aff-quais__train-number--strict { color: #173771; font-weight: 400; font-size: 22px; } .aff-quais__quai{ margin-top: 8px; color:#173771; font-weight:800; background: rgba(23,55,113,0.08); padding:6px 10px; border-radius:6px; display:inline-block; } /* central divider */ .aff-quais__divider{ width:18px; background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.18)); display:flex; justify-content:center; align-items:flex-start; padding-top:36px; } .aff-quais__divider:before{ content:''; width:6px; background: transparent; box-shadow: 0 18px 0 0 #f0e34f, 0 54px 0 0 #f0e34f, 0 90px 0 0 #f0e34f, 0 126px 0 0 #f0e34f, 0 162px 0 0 #f0e34f, 0 198px 0 0 #f0e34f; display:block; border-radius:4px; } /* Right panel with stops */ .aff-quais__right{ flex:1; background: linear-gradient(180deg,#205aa1 0%, #144b85 100%); display:flex; align-items:center; box-sizing:border-box; padding: 24px 36px; } .aff-quais__stops-box{ width:100%; max-width:820px; margin-left:24px; } .aff-quais__stops-list{ list-style:none; margin:0; padding:0; width:100%; } .aff-quais__stop{ display:flex; align-items:center; gap:18px; padding: 10px 16px; color:#fff; font-size:30px; line-height:1.1; } .aff-quais__bullet{ width:14px; height:14px; background: #f0e34f; /* yellow bullet */ border-radius:50%; display:inline-block; box-shadow: 0 0 0 4px rgba(240,227,79,0.06) inset; } .aff-quais__stop--active{ background: rgba(155,123,224,0.15); border-radius:6px; } .aff-quais__stop--active .aff-quais__bullet{ background:#9b7be0; /* purple for final */ } .aff-quais__stop-text{ flex:1; padding-left:6px; } /* --- STRICT STYLES FOR RIGHT PANEL --- */ .aff-quais__right--strict { flex: 1; background: #205aa1; /* fond bleu uniforme */ display: flex; align-items: flex-start; justify-content: flex-start; padding: 48px 0 48px 0; min-width: 0; height: 100vh; box-sizing: border-box; } .aff-quais__stops-list--strict { list-style: none; margin: 0; padding: 0 0 0 38px; width: 100%; max-width: 600px; } .aff-quais__stop--strict { display: flex; align-items: center; font-size: 2.1rem; color: #fff; font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; font-weight: 400; margin-bottom: 6px; line-height: 1.18; padding: 0 0 0 0; } .aff-quais__stop-bullet--strict { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: #ffe600; margin-right: 16px; flex-shrink: 0; margin-top: 2px; } .aff-quais__stop-text--strict { display: inline-block; font-size: inherit; font-weight: inherit; color: inherit; } .aff-quais__stop--strict-dest { font-weight: 800; background: #b6d2f7; color: #205aa1; border-radius: 6px; padding: 2px 12px 2px 0; margin-left: -16px; } @media (max-width: 900px) { .aff-quais__right--strict { padding: 18px 0 18px 0; } .aff-quais__stops-list--strict { padding-left: 12px; } .aff-quais__stop--strict { font-size: 1.1rem; } .aff-quais__stop-bullet--strict { width: 8px; height: 8px; margin-right: 8px; } .aff-quais__stop--strict-dest { padding: 1px 6px 1px 0; border-radius: 4px; } } /* --- END STRICT STYLES FOR RIGHT PANEL --- */ .aff-quais__footer{ position:absolute; left:0; right:0; bottom:0; height:86px; pointer-events:none; } .aff-quais__footer-inner{ display:flex; justify-content:space-between; align-items:center; height:100%; padding: 0 28px; background: linear-gradient(to bottom, rgba(0,0,0,0.15), rgba(0,0,0,0.25)); } .aff-quais__footer-left{ color:#fff; font-weight:700; font-size:20px; } .aff-quais__clock-box{ background: #173771; color:#fff; padding: 6px 12px; border-radius: 8px; border: 6px solid #ffffff; } .aff-quais__clock-main{ font-size:20px; font-weight:800; } /* position the clock bottom-right inside a small white rounded box like on the image */ .aff-quais__footer-right{ position:relative; } .aff-quais__clock{ position:relative; } .aff-quais__clock-box{ background: transparent; border-radius:6px; border: 4px solid #ffffff; padding:4px 8px; background: rgba(23,55,113,0.9); } .aff-quais__clock-main{ font-size:18px } @media (max-width: 900px){ .aff-quais__left{width:42%;} .aff-quais__destination-line1{font-size:36px} .aff-quais__destination-line2{font-size:28px} .aff-quais__time{font-size:48px} } " />
|
|
<option name="updatedContent" value="/* Partie gauche strictement comme l'image */ .aff-quais__left--strict { width: 100%; max-width: 480px; min-width: 340px; background: #fff; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; padding: 32px 32px 24px 32px; box-sizing: border-box; position: relative; height: 100vh; overflow: hidden; border-top-right-radius: 0; border-bottom-right-radius: 0; box-shadow: none; } .aff-quais__sncf-logo--strict { width: 54px; height: auto; margin-bottom: 18px; margin-left: 0; margin-top: 0; } .aff-quais__watermark { position: absolute; right: 18px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 100px; color: rgba(23,55,113,0.07); font-weight: 800; letter-spacing: 6px; pointer-events: none; user-select: none; z-index: 0; } .aff-quais__row--time-status { display: flex; align-items: flex-end; gap: 18px; margin-bottom: 18px; z-index: 1; } .aff-quais__time--strict { color: #0d4a87; font-weight: 800; font-size: 56px; line-height: 1; letter-spacing: 0.5px; margin-right: 12px; } .aff-quais__time-status--strict { color: #0d4a87; font-size: 22px; font-weight: 400; opacity: 1; align-self: flex-end; margin-bottom: 7px; } .aff-quais__destination--strict { margin-top: 10px; margin-bottom: 0; color: #0d4a87; z-index: 1; } .aff-quais__destination-line1--strict { font-size: 38px; font-weight: 800; line-height: 1.1; } .aff-quais__destination-line2--strict { font-size: 38px; font-weight: 800; line-height: 1.1; } .aff-quais__train-id--strict { margin-top: 12px; color: #173771; font-weight: 700; font-size: 22px; letter-spacing: 0.5px; z-index: 1; } .aff-quais__train-number--strict { color: #173771; font-weight: 400; font-size: 22px; } .aff-quais__quai{ margin-top: 8px; color:#173771; font-weight:800; background: rgba(23,55,113,0.08); padding:6px 10px; border-radius:6px; display:inline-block; } /* central divider */ .aff-quais__divider{ width:18px; background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.18)); display:flex; justify-content:center; align-items:flex-start; padding-top:36px; } .aff-quais__divider:before{ content:''; width:6px; background: transparent; box-shadow: 0 18px 0 0 #f0e34f, 0 54px 0 0 #f0e34f, 0 90px 0 0 #f0e34f, 0 126px 0 0 #f0e34f, 0 162px 0 0 #f0e34f, 0 198px 0 0 #f0e34f; display:block; border-radius:4px; } /* Right panel with stops */ .aff-quais__right{ flex:1; background: linear-gradient(180deg,#205aa1 0%, #144b85 100%); display:flex; align-items:center; box-sizing:border-box; padding: 24px 36px; } .aff-quais__stops-box{ width:100%; max-width:820px; margin-left:24px; } .aff-quais__stops-list{ list-style:none; margin:0; padding:0; width:100%; } .aff-quais__stop{ display:flex; align-items:center; gap:18px; padding: 10px 16px; color:#fff; font-size:30px; line-height:1.1; } .aff-quais__bullet{ width:14px; height:14px; background: #f0e34f; /* yellow bullet */ border-radius:50%; display:inline-block; box-shadow: 0 0 0 4px rgba(240,227,79,0.06) inset; } .aff-quais__stop--active{ background: rgba(155,123,224,0.15); border-radius:6px; } .aff-quais__stop--active .aff-quais__bullet{ background:#9b7be0; /* purple for final */ } .aff-quais__stop-text{ flex:1; padding-left:6px; } /* --- STRICT STYLES FOR RIGHT PANEL --- */ .aff-quais__right--strict { flex: 1; background: #205aa1; /* fond bleu uniforme */ display: flex; align-items: flex-start; justify-content: flex-start; padding: 48px 0 48px 0; min-width: 0; height: 100vh; box-sizing: border-box; border-top-left-radius: 0; border-bottom-left-radius: 0; box-shadow: none; } .aff-quais__stops-list--strict { list-style: none; margin: 0; padding: 0 0 0 38px; width: 100%; max-width: 600px; } .aff-quais__stop--strict { display: flex; align-items: center; font-size: 2.1rem; color: #fff; font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; font-weight: 400; margin-bottom: 6px; line-height: 1.18; padding: 0 0 0 0; } .aff-quais__stop-bullet--strict { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: #ffe600; margin-right: 16px; flex-shrink: 0; margin-top: 2px; } .aff-quais__stop-text--strict { display: inline-block; font-size: inherit; font-weight: inherit; color: inherit; } .aff-quais__stop--strict-dest { font-weight: 800; background: #b6d2f7; color: #205aa1; border-radius: 6px; padding: 2px 12px 2px 0; margin-left: -16px; } .aff-quais__container--strict { display: flex; flex-direction: row; width: 100vw; height: 100vh; min-height: 0; min-width: 0; background: #205aa1; box-sizing: border-box; overflow: hidden; } .aff-quais__separator--strict { width: 14px; min-width: 14px; max-width: 14px; background: #205aa1; height: 100vh; display: block; z-index: 2; } @media (max-width: 900px) { .aff-quais__right--strict { padding: 18px 0 18px 0; } .aff-quais__stops-list--strict { padding-left: 12px; } .aff-quais__stop--strict { font-size: 1.1rem; } .aff-quais__stop-bullet--strict { width: 8px; height: 8px; margin-right: 8px; } .aff-quais__stop--strict-dest { padding: 1px 6px 1px 0; border-radius: 4px; } } /* --- END STRICT STYLES FOR RIGHT PANEL --- */ .aff-quais__footer{ position:absolute; left:0; right:0; bottom:0; height:86px; pointer-events:none; } .aff-quais__footer-inner{ display:flex; justify-content:space-between; align-items:center; height:100%; padding: 0 28px; background: linear-gradient(to bottom, rgba(0,0,0,0.15), rgba(0,0,0,0.25)); } .aff-quais__footer-left{ color:#fff; font-weight:700; font-size:20px; } .aff-quais__clock-box{ background: #173771; color:#fff; padding: 6px 12px; border-radius: 8px; border: 6px solid #ffffff; } .aff-quais__clock-main{ font-size:20px; font-weight:800; } /* position the clock bottom-right inside a small white rounded box like on the image */ .aff-quais__footer-right{ position:relative; } .aff-quais__clock{ position:relative; } .aff-quais__clock-box{ background: transparent; border-radius:6px; border: 4px solid #ffffff; padding:4px 8px; background: rgba(23,55,113,0.9); } .aff-quais__clock-main{ font-size:18px } @media (max-width: 900px){ .aff-quais__left{width:42%;} .aff-quais__destination-line1{font-size:36px} .aff-quais__destination-line2{font-size:28px} .aff-quais__time{font-size:48px} }" />
|
|
</PendingDiffInfo>
|
|
</value>
|
|
</entry>
|
|
<entry key="$PROJECT_DIR$/.env.example">
|
|
<value>
|
|
<PendingDiffInfo>
|
|
<option name="filePath" value="$PROJECT_DIR$/.env.example" />
|
|
<option name="updatedContent" value="# Exemple de variables d'environnement pour CRA (create-react-app) # Copiez ce fichier en `.env.local` ou `.env` à la racine du projet # et remplacez par vos valeurs Supabase. REACT_APP_SUPABASE_URL=https://xyzcompany.supabase.co REACT_APP_SUPABASE_ANON_KEY=public-anon-key " />
|
|
</PendingDiffInfo>
|
|
</value>
|
|
</entry>
|
|
<entry key="$PROJECT_DIR$/src/pages/afficheurs/classiques/ClassiquesDeparts.js">
|
|
<value>
|
|
<PendingDiffInfo>
|
|
<option name="filePath" value="$PROJECT_DIR$/src/pages/afficheurs/classiques/ClassiquesDeparts.js" />
|
|
<option name="originalContent" value="import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesDeparts.css'; import supabase from '../../../lib/supabase'; // 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'); }; // Données proviennent exclusivement de la table `horaires`. function Logo({op}){ // Tente de charger un logo SVG depuis /type/ en fonction de l'opérateur. // mapping pour opérateurs connus -> noms de fichiers dans public/type const mapping = { 	'TGV inOUI': 'logo-inoui.svg', 	'TGV inOUI ': 'logo-inoui.svg', 	'TGV': 'logo-tgv.svg', 	'TER': 'sncf-voyageurs-logo.png', 	'MOBIGO': 'logo-mobigo.svg', 	'TGV Lyria': 'logo-lyria.svg', 	'TGV Lyria ': 'logo-lyria.svg', }; const defaultFileFromOp = (name) => { 	if (!name) return null; 	// nettoyer et fabriquer un nom plausible : "logo-<slug>.svg" 	const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 	return `logo-${slug}.svg`; }; const fileName = mapping[op] || mapping[op.split(' ')[0]] || defaultFileFromOp(op); const [hasImg, setHasImg] = React.useState(true); return ( 	<div className="aff-classiques-departs__logo-box" aria-hidden> 	 {hasImg && fileName ? ( 		<img 		 src={`/type/${fileName}`} 		 alt={op} 		 className="aff-classiques-departs__op-logo" 		 onError={()=> setHasImg(false)} 		/> 	 ) : ( 		<div className="aff-classiques-departs__logo-text" aria-hidden>{op.split(' ')[0]}</div> 	 )} 	</div> ); } // (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-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(){ // 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; // 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]); // État pour alterner l'affichage toutes les 3 secondes const [showStatus, setShowStatus] = useState(false); // 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: [] }); // --- 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((type) => { 	if (!type) return 0; 	const t = String(type).toLowerCase(); 	if (t.includes('interurb') || t.includes('interurbaine')) return 360; // 6 heures 	if (t.includes('ville')) return 30; // 30 minutes 	return 0; }, []); 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 const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { 	const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; 				try { debug('computeStatusForHoraireAtStop', { id: horaire && horaire.id, numero: horaire && (horaire.numero_train || horaire.numero), stationId, stationName, found: found.length }); } catch (e) {} 	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; 	for (const p of found) { 	 // date filter: if perturbation carries explicit dates or jours_perturbation, check they match horaireYmd 	 try { 		if (horaireYmd) { 		 const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); 		 const db = toYMD(p.date_debut || p.start_date); 		 const df = toYMD(p.date_fin || p.end_date); 		 if (pDate && pDate !== horaireYmd) { debug('perturbation date mismatch', p.id); continue; } 		 if (db && df && !(horaireYmd >= db && horaireYmd <= df)) { debug('perturbation date-range mismatch', p.id); continue; } 		} 	 } catch (e) { /* ignore date parse errors -> assume applies */ } 	 // also check jours_perturbation (may be inside perturbations_arrets wrapper) 	 try { 		const joursSet = getPerturbationJours(p); 		if (joursSet && horaireYmd) { 		 if (!joursSet.has(horaireYmd)) { debug('perturbation jours mismatch', p.id); continue; } 		} 	 } catch (e) {} 	 const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; 	 const arrets = parsedObj.arrets || []; 	 let matched = false; 			for (const a of arrets) { 				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; 				const na = normalizeName(aName || ''); 				const ns = normalizeName(stationName || ''); 				const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); 				if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { 		 matched = true; 		 const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); 		 if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; 		 if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { 			const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; 			if (maxDelay == null || d > maxDelay) maxDelay = d; 		 } 		} 	 } 	 if (!matched) { 		// top-level perturbation that references the horaire/numero 		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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; 		if (appliesByRef || appliesByNumber || isGlobal) { 		 const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); 		 if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; 		 if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { 			const d = parseDelayMinutesFromArret(p) || 0; 			if (maxDelay == null || d > maxDelay) maxDelay = d; 		 } 		} 	 } 	} 	if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; 	return { label: "à l'heure", detail: '', delayMinutes: null }; }, [findPerturbsForHoraireAndStop, parsePerturbArretsObject, getPerturbationJours, parseDelayMinutesFromArret, normalizeName]); // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée const applyPerturbationsToHoraires = useCallback((list = [], perturbList = perturbations, 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 || ''); if (displayStatus && typeof displayStatus.delayMinutes === 'number' && !isNaN(displayStatus.delayMinutes)) { if (typeof horaire._depHM === 'number') { displayTime = formatTimeFromHM(horaire._depHM + displayStatus.delayMinutes); } else { const tryParse = (s) => { if (!s) return null; const m = String(s).match(/(\d{1,2}):(\d{2})/); if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10); const d = new Date(s); if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes(); return null; }; const base = tryParse(horaire._rawTime || horaire.heure_depart || horaire.time || horaire.heure || ''); if (typeof base === 'number') displayTime = formatTimeFromHM(base + displayStatus.delayMinutes); } } 	 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 || ns.includes(target) || target.includes(ns))); 	 }) || null; 	 horaire._status = displayStatus; 	 horaire._displayTime = displayTime; 	 horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.platform || stopForStation.quay)) || horaire.platform || ''; 	 return horaire; 	}); }, [buildPerturbMap, computeStatusForHoraireAtStop, formatTimeFromHM, perturbations, gareDisplay, gareIdParam, getStops, normalizeName, debug]); useEffect(() => { 	const id = setInterval(() => setShowStatus(s => !s), 3000); 	return () => clearInterval(id); }, []); // 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 || []); // Récupérer le type de gare depuis la table `gares` si possible 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 needle = `%${gareDisplay}%`; try { const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').or(`nom.ilike.${needle},name.ilike.${needle}`).limit(1); if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; } catch (e) { try { const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', needle).limit(1); if (gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; } catch (err) { /* ignore */ } } } if (mounted) setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); } catch (e) { /* ignore errors reading gares table */ } 		// 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 && (destName === normalized || destName.includes(normalized) || normalized.includes(destName))) 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 && (departName === normalized || departName.includes(normalized) || normalized.includes(departName))) 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 || stopName.includes(normalized) || normalized.includes(stopName)) 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 => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; 																		 const stop = byId || byName; 																		 if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure; 																		} 						const depHM = parseTimeHM(raw); 						const displayTime = 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 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 circulatesOn = (h, d) => { 		 const col = weekdayNameForColumn(d); 		 if (!col) return true; 		 const v = h[col]; 		 if (typeof v === 'boolean') return v; 		 if (typeof v === 'number') return v === 1; 		 if (typeof v === 'string') { 			const s = v.trim().toLowerCase(); 			return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; 		 } 		 // si la colonne est undefined ou null, on considère que ça circule (fallback permissif) 		 return (typeof v === 'undefined' || v === null) ? true : Boolean(v); 		}; 		// 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; 		}; 		const isSubstitution = (h) => { 		 if (!h) return false; 		 // nouvelle colonne spécifique 		 if (h.substitution_disponible === true) return true; 		 // anciennes variantes 		 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); 		}; 		// compute linkage and travaux applicability per horaire 		const enriched = enrich.map(h => { 		 let linked = false; 		 let appliesToday = false; 		 let appliesTomorrow = false; 		 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; 			if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true; 			if (!appliesToday && travauxApplies(t,h,today)) appliesToday = true; 			if (!appliesTomorrow && travauxApplies(t,h,tomorrow)) appliesTomorrow = true; 			if (linked && appliesToday && appliesTomorrow) break; 		 } 		 return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow }; 		}); 		// 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) 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)); 		 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 todayYmd = new Date().toLocaleDateString('en-CA'); 		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 || p.start_date || p.date_debut); 			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 à aujourd'hui -> ignorer 			if (pDate && pDate !== todayYmd) return false; 			// si fourche de dates fournie et aujourd'hui n'est pas dans l'intervalle -> ignorer 			if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; 			// si jours_perturbation fourni -> vérifier qu'aujourd'hui en fait partie 			try { 			 const joursSet = getPerturbationJours(p); 			 if (joursSet && !joursSet.has(todayYmd)) 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) {} 		// afficher seulement les 9 premières lignes sur l'afficheur 		setHoraires((annotated || []).slice(0, 9)); 		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]); // Helpers pour perturbations (memoized plus haut) return ( 	<div className="aff-classiques-departs__container"> 	 {/* nom de la gare configurable via ?gare=Nom-de-la-gare */} 	 <div className="aff-classiques-departs__watermark">départs</div> 	 <ul className="aff-classiques-departs__list"> 		{horaires.length === 0 ? ( 		 <li className="aff-classiques-departs__row aff-classiques-departs__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) => { 		 const horaire = h || {}; 		 const op = horaire.type_train || horaire.op || ''; 		 const number = horaire.numero_train || horaire.number || horaire.numero || ''; 		 // Use enriched display time (computed during fetch) — fallback to formatting raw fields 		 let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); 		 const destination = horaire.gare_arrivee_nom || ''; 		 const platform = (horaire._displayPlatform || horaire.platform || '').toString().trim(); 		 // attachedPerturbs/rowObj previously used for mocked status; status is now computed from perturbations per stop 		 // determine station context (the display station) — prefer explicit gareIdParam/gareDisplay if provided 		 const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); 		 const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); 		 // prefer precomputed status and display time (synchronisé depuis la BDD) — sinon calculer à la volée 		 const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); 		 // if the status is 'supprimé' and the component is currently showing the status (showStatus toggles every 3s), hide the time 		 if (status && status.label === 'supprimé' && showStatus) { 			time = ''; 		 } else if (!horaire._displayTime && status && typeof status.delayMinutes === 'number' && !isNaN(status.delayMinutes)) { 			// if we have _depHM (minutes since midnight) use it 			if (typeof horaire._depHM === 'number') { 			 time = formatTimeFromHM(horaire._depHM + status.delayMinutes); 			} else { 			 // fallback: try to parse original time string 			 const tryParse = (s) => { 				if (!s) return null; 				const m = String(s).match(/(\d{1,2}):(\d{2})/); 				if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10); 				const d = new Date(s); 				if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes(); 				return null; 			 }; 			 const base = tryParse(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); 			 if (typeof base === 'number') time = formatTimeFromHM(base + status.delayMinutes); 			} 		 } 		 const statusClass = status.label === 'supprimé' ? 'aff-classiques-departs__train-meta--suppressed' : (status.label && String(status.label).startsWith('retardé') ? 'aff-classiques-departs__train-meta--delayed' : ''); 		 // Ne pas afficher le quai si le train est supprimé ou si aucun quai n'est défini 		 let showPlatform = !(status && status.label === 'supprimé') && Boolean(platform); 		 if (showPlatform) { 			const now = new Date(); 			const nowHM = now.getHours()*60 + now.getMinutes(); 			const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); 			const threshold = thresholdForStationType(stationType); 			if (typeof sched === 'number' && typeof threshold === 'number' && threshold > 0) { 			 // n'afficher le quai que si l'heure courante est au moins (scheduled - threshold) 			 showPlatform = (nowHM >= (sched - threshold)); 			} 		 } 		 return ( 			<li className="aff-classiques-departs__row" key={idx}> 			 <div className="aff-classiques-departs__col-logo"> 				<Logo op={op} /> 			 </div> 			 <div className="aff-classiques-departs__col-train-type"> 				{showStatus ? ( 				 <div className={`aff-classiques-departs__train-meta aff-classiques-departs__train-status ${statusClass}`} aria-live="polite"> 					<div className="aff-classiques-departs__op-name">{status.label}</div> 					<div className="aff-classiques-departs__op-number">{status.detail}</div> 				 </div> 				) : ( 				 <div className="aff-classiques-departs__train-meta"> 					<div className="aff-classiques-departs__op-name">{op}</div> 					<div className="aff-classiques-departs__op-number">{number}</div> 				 </div> 				)} 			 </div> 			 <div className="aff-classiques-departs__col-time"> 				<div className="aff-classiques-departs__time">{time}</div> 			 </div> 			 <div className="aff-classiques-departs__col-destination"> 				<div className="aff-classiques-departs__destination">{destination}</div> 			 </div> 			 {showPlatform ? ( 				<div className="aff-classiques-departs__col-platform"> 				 <div className="aff-classiques-departs__platform-box"> 					<div className="aff-classiques-departs__platform-text">{platform}</div> 				 </div> 				</div> 			 ) : null} 			</li> 		 ); 		})} 	 </ul> 				 {/* Footer visuel de l'afficheur — barre foncée en bas */} 		<div className="aff-classiques-departs__footer" aria-hidden> 			<div className="aff-classiques-departs__footer-inner"> 				<Clock /> 			</div> 		</div> 	</div> ) } " />
|
|
<option name="updatedContent" value="import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesDeparts.css'; import supabase from '../../../lib/supabase'; // 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'); }; // Données proviennent exclusivement de la table `horaires`. function Logo({op}){ // Tente de charger un logo SVG depuis /type/ en fonction de l'opérateur. // mapping pour opérateurs connus -> noms de fichiers dans public/type const mapping = { 	'TGV inOUI': 'logo-inoui.svg', 	'TGV inOUI ': 'logo-inoui.svg', 	'TGV': 'logo-tgv.svg', 	'TER': 'sncf-voyageurs-logo.png', 	'MOBIGO': 'logo-mobigo.svg', 	'TGV Lyria': 'logo-lyria.svg', 	'TGV Lyria ': 'logo-lyria.svg', }; const defaultFileFromOp = (name) => { 	if (!name) return null; 	// nettoyer et fabriquer un nom plausible : "logo-<slug>.svg" 	const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 	return `logo-${slug}.svg`; }; const fileName = mapping[op] || mapping[op.split(' ')[0]] || defaultFileFromOp(op); const [hasImg, setHasImg] = React.useState(true); return ( 	<div className="aff-classiques-departs__logo-box" aria-hidden> 	 {hasImg && fileName ? ( 		<img 		 src={`/type/${fileName}`} 		 alt={op} 		 className="aff-classiques-departs__op-logo" 		 onError={()=> setHasImg(false)} 		/> 	 ) : ( 		<div className="aff-classiques-departs__logo-text" aria-hidden>{op.split(' ')[0]}</div> 	 )} 	</div> ); } // (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-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(){ // 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; // 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]); // État pour alterner l'affichage toutes les 3 secondes const [showStatus, setShowStatus] = useState(false); // 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: [] }); // --- 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((type) => { 	if (!type) return 0; 	const t = String(type).toLowerCase(); 	if (t.includes('interurb') || t.includes('interurbaine')) return 360; // 6 heures 	if (t.includes('ville')) return 30; // 30 minutes 	return 0; }, []); 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 const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { 	const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; 				try { debug('computeStatusForHoraireAtStop', { id: horaire && horaire.id, numero: horaire && (horaire.numero_train || horaire.numero), stationId, stationName, found: found.length }); } catch (e) {} 	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; 	for (const p of found) { 	 // date filter: if perturbation carries explicit dates or jours_perturbation, check they match horaireYmd 	 try { 		if (horaireYmd) { 		 const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); 		 const db = toYMD(p.date_debut || p.start_date); 		 const df = toYMD(p.date_fin || p.end_date); 		 if (pDate && pDate !== horaireYmd) { debug('perturbation date mismatch', p.id); continue; } 		 if (db && df && !(horaireYmd >= db && horaireYmd <= df)) { debug('perturbation date-range mismatch', p.id); continue; } 		} 	 } catch (e) { /* ignore date parse errors -> assume applies */ } 	 // also check jours_perturbation (may be inside perturbations_arrets wrapper) 	 try { 		const joursSet = getPerturbationJours(p); 		if (joursSet && horaireYmd) { 		 if (!joursSet.has(horaireYmd)) { debug('perturbation jours mismatch', p.id); continue; } 		} 	 } catch (e) {} 	 const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; 	 const arrets = parsedObj.arrets || []; 	 let matched = false; 			for (const a of arrets) { 				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; 				const na = normalizeName(aName || ''); 				const ns = normalizeName(stationName || ''); 				const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); 				if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { 		 matched = true; 		 const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); 		 if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; 		 if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { 			const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; 			if (maxDelay == null || d > maxDelay) maxDelay = d; 		 } 		} 	 } 	 if (!matched) { 		// top-level perturbation that references the horaire/numero 		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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; 		if (appliesByRef || appliesByNumber || isGlobal) { 		 const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); 		 if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; 		 if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { 			const d = parseDelayMinutesFromArret(p) || 0; 			if (maxDelay == null || d > maxDelay) maxDelay = d; 		 } 		} 	 } 	} 	if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; 	return { label: "à l'heure", detail: '', delayMinutes: null }; }, [findPerturbsForHoraireAndStop, parsePerturbArretsObject, getPerturbationJours, parseDelayMinutesFromArret, normalizeName, debug]); // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée const applyPerturbationsToHoraires = useCallback((list = [], perturbList = perturbations, 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 || ''); if (displayStatus && typeof displayStatus.delayMinutes === 'number' && !isNaN(displayStatus.delayMinutes)) { if (typeof horaire._depHM === 'number') { displayTime = formatTimeFromHM(horaire._depHM + displayStatus.delayMinutes); } else { const tryParse = (s) => { if (!s) return null; const m = String(s).match(/(\d{1,2}):(\d{2})/); if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10); const d = new Date(s); if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes(); return null; }; const base = tryParse(horaire._rawTime || horaire.heure_depart || horaire.time || horaire.heure || ''); if (typeof base === 'number') displayTime = formatTimeFromHM(base + displayStatus.delayMinutes); } } 	 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 || ns.includes(target) || target.includes(ns))); 	 }) || null; 	 horaire._status = displayStatus; 	 horaire._displayTime = displayTime; 	 horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.platform || stopForStation.quay)) || horaire.platform || ''; 	 return horaire; 	}); }, [buildPerturbMap, computeStatusForHoraireAtStop, formatTimeFromHM, perturbations, gareDisplay, gareIdParam, getStops, normalizeName]); useEffect(() => { 	const id = setInterval(() => setShowStatus(s => !s), 3000); 	return () => clearInterval(id); }, []); // 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 || []); // Récupérer le type de gare depuis la table `gares` si possible 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 needle = `%${gareDisplay}%`; try { const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').or(`nom.ilike.${needle},name.ilike.${needle}`).limit(1); if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; } catch (e) { try { const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', needle).limit(1); if (gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; } catch (err) { /* ignore */ } } } if (mounted) setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); } catch (e) { /* ignore errors reading gares table */ } 		// 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 && (destName === normalized || destName.includes(normalized) || normalized.includes(destName))) 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 && (departName === normalized || departName.includes(normalized) || normalized.includes(departName))) 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 || stopName.includes(normalized) || normalized.includes(stopName)) 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 => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; 																		 const stop = byId || byName; 																		 if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure; 																		} 						const depHM = parseTimeHM(raw); 						const displayTime = 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 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 circulatesOn = (h, d) => { 		 const col = weekdayNameForColumn(d); 		 if (!col) return true; 		 const v = h[col]; 		 if (typeof v === 'boolean') return v; 		 if (typeof v === 'number') return v === 1; 		 if (typeof v === 'string') { 			const s = v.trim().toLowerCase(); 			return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; 		 } 		 // si la colonne est undefined ou null, on considère que ça circule (fallback permissif) 		 return (typeof v === 'undefined' || v === null) ? true : Boolean(v); 		}; 		// 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; 		}; 		const isSubstitution = (h) => { 		 if (!h) return false; 		 // nouvelle colonne spécifique 		 if (h.substitution_disponible === true) return true; 		 // anciennes variantes 		 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); 		}; 		// compute linkage and travaux applicability per horaire 		const enriched = enrich.map(h => { 		 let linked = false; 		 let appliesToday = false; 		 let appliesTomorrow = false; 		 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; 			if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true; 			if (!appliesToday && travauxApplies(t,h,today)) appliesToday = true; 			if (!appliesTomorrow && travauxApplies(t,h,tomorrow)) appliesTomorrow = true; 			if (linked && appliesToday && appliesTomorrow) break; 		 } 		 return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow }; 		}); 		// 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) 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)); 		 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 todayYmd = new Date().toLocaleDateString('en-CA'); 		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 || p.start_date || p.date_debut); 			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 à aujourd'hui -> ignorer 			if (pDate && pDate !== todayYmd) return false; 			// si fourche de dates fournie et aujourd'hui n'est pas dans l'intervalle -> ignorer 			if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; 			// si jours_perturbation fourni -> vérifier qu'aujourd'hui en fait partie 			try { 			 const joursSet = getPerturbationJours(p); 			 if (joursSet && !joursSet.has(todayYmd)) 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) {} 		// afficher seulement les 9 premières lignes sur l'afficheur 		setHoraires((annotated || []).slice(0, 9)); 		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]); // Helpers pour perturbations (memoized plus haut) return ( 	<div className="aff-classiques-departs__container"> 	 {/* nom de la gare configurable via ?gare=Nom-de-la-gare */} 	 <div className="aff-classiques-departs__watermark">départs</div> 	 <ul className="aff-classiques-departs__list"> 		{horaires.length === 0 ? ( 		 <li className="aff-classiques-departs__row aff-classiques-departs__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) => { 		 const horaire = h || {}; 		 const op = horaire.type_train || horaire.op || ''; 		 const number = horaire.numero_train || horaire.number || horaire.numero || ''; 		 // Use enriched display time (computed during fetch) — fallback to formatting raw fields 		 let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); 		 const destination = horaire.gare_arrivee_nom || ''; 		 const platform = (horaire._displayPlatform || horaire.platform || '').toString().trim(); 		 // attachedPerturbs/rowObj previously used for mocked status; status is now computed from perturbations per stop 		 // determine station context (the display station) — prefer explicit gareIdParam/gareDisplay if provided 		 const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); 		 const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); 		 // prefer precomputed status and display time (synchronisé depuis la BDD) — sinon calculer à la volée 		 const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); 		 // if the status is 'supprimé' and the component is currently showing the status (showStatus toggles every 3s), hide the time 		 if (status && status.label === 'supprimé' && showStatus) { 			time = ''; 		 } else if (!horaire._displayTime && status && typeof status.delayMinutes === 'number' && !isNaN(status.delayMinutes)) { 			// if we have _depHM (minutes since midnight) use it 			if (typeof horaire._depHM === 'number') { 			 time = formatTimeFromHM(horaire._depHM + status.delayMinutes); 			} else { 			 // fallback: try to parse original time string 			 const tryParse = (s) => { 				if (!s) return null; 				const m = String(s).match(/(\d{1,2}):(\d{2})/); 				if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10); 				const d = new Date(s); 				if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes(); 				return null; 			 }; 			 const base = tryParse(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); 			 if (typeof base === 'number') time = formatTimeFromHM(base + status.delayMinutes); 			} 		 } 		 const statusClass = status.label === 'supprimé' ? 'aff-classiques-departs__train-meta--suppressed' : (status.label && String(status.label).startsWith('retardé') ? 'aff-classiques-departs__train-meta--delayed' : ''); 		 // Ne pas afficher le quai si le train est supprimé ou si aucun quai n'est défini 		 let showPlatform = !(status && status.label === 'supprimé') && Boolean(platform); 		 if (showPlatform) { 			const now = new Date(); 			const nowHM = now.getHours()*60 + now.getMinutes(); 			const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); 			const threshold = thresholdForStationType(stationType); 			if (typeof sched === 'number' && typeof threshold === 'number' && threshold > 0) { 			 // n'afficher le quai que si l'heure courante est au moins (scheduled - threshold) 			 showPlatform = (nowHM >= (sched - threshold)); 			} 		 } 		 return ( 			<li className="aff-classiques-departs__row" key={idx}> 			 <div className="aff-classiques-departs__col-logo"> 				<Logo op={op} /> 			 </div> 			 <div className="aff-classiques-departs__col-train-type"> 				{showStatus ? ( 				 <div className={`aff-classiques-departs__train-meta aff-classiques-departs__train-status ${statusClass}`} aria-live="polite"> 					<div className="aff-classiques-departs__op-name">{status.label}</div> 					<div className="aff-classiques-departs__op-number">{status.detail}</div> 				 </div> 				) : ( 				 <div className="aff-classiques-departs__train-meta"> 					<div className="aff-classiques-departs__op-name">{op}</div> 					<div className="aff-classiques-departs__op-number">{number}</div> 				 </div> 				)} 			 </div> 			 <div className="aff-classiques-departs__col-time"> 				<div className="aff-classiques-departs__time">{time}</div> 			 </div> 			 <div className="aff-classiques-departs__col-destination"> 				<div className="aff-classiques-departs__destination">{destination}</div> 			 </div> 			 {showPlatform ? ( 				<div className="aff-classiques-departs__col-platform"> 				 <div className="aff-classiques-departs__platform-box"> 					<div className="aff-classiques-departs__platform-text">{platform}</div> 				 </div> 				</div> 			 ) : null} 			</li> 		 ); 		})} 	 </ul> 				 {/* Footer visuel de l'afficheur — barre foncée en bas */} 		<div className="aff-classiques-departs__footer" aria-hidden> 			<div className="aff-classiques-departs__footer-inner"> 				<Clock /> 			</div> 		</div> 	</div> ) } " />
|
|
</PendingDiffInfo>
|
|
</value>
|
|
</entry>
|
|
<entry key="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js">
|
|
<value>
|
|
<PendingDiffInfo>
|
|
<option name="filePath" value="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js" />
|
|
<option name="originalContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react'; import './ClassiquesArriveesQuais.css'; import '../ClassiquesArrivees.css'; /* ClassiquesArriveesQuais.js Variante "Arrivées - quais" du composant quais. Différences principales avec la version départs: - utilise `heure_arrivee` / `gare_arrivee_*` lorsque disponible pour afficher l'heure et la destination - watermark affiché: "arrivée" - styles importés depuis `ClassiquesArriveesQuais.css` qui reprend la palette de `ClassiquesArrivees.css` Champs utilisés pour le calcul/affichage: - horaire.heure_arrivee, horaire.time, horaire.heure - horaire.gare_arrivee_nom, horaire.gare_arrivee - stop.heure_arrivee, stop.gare_nom, stop.gare_id - perturbations: mêmes clés que pour les départs (retard, retard_minutes, perturbations_arrets, etc.) */ import supabase from '../../../../lib/supabase'; // Hook pour récupérer la map des logos depuis /type/data.json function useTypeLogos(){ const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' }); useEffect(() => { let mounted = true; const load = async () => { const candidates = ['/type/quais/data-color.json', '/type/data.json']; for(const url of candidates){ try{ const res = await fetch(url); if(!res.ok) continue; const data = await res.json(); if(!mounted) return; const logos = {}; if(Array.isArray(data.logos)){ data.logos.forEach(item => { // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path let file = item.file; if(!file && item.path){ const parts = String(item.path).split('/'); file = parts[parts.length-1]; } if(!file) return; const basename = file.replace(/\.[^/.]+$/, ''); const semantic = basename.replace(/^logo-/, ''); const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-'); // Prefer color logos in the 'quais' subfolder if data came from the quais file if(url.includes('/quais/')){ logos[key] = `/type/quais/${file}`; } else { logos[key] = `/type/${file}`; } }); } logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png'; setMap(logos); return; }catch(e){ // try next candidate (ignore error and continue loop) } } // no candidate succeeded — keep fallback }; load(); return () => { mounted = false; }; }, []); return map; } function getTypeLogo(type, TYPE_LOGOS_MAP){ const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' }; if(!type) return map.default; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-'); if(map[norm]) return map[norm]; const keys = Object.keys(map).filter(k => k !== 'default'); for(const k of keys){ if(k.includes(norm) || norm.includes(k)) return map[k]; } return map.default; } 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-quais__clock"> <div className="aff-quais__clock-box"><span className="aff-quais__clock-main">{hh}:{mm}:{ss}</span></div> </div> ); } export default function ClassiquesArriveesQuais(){ const typeLogos = useTypeLogos(); const rightRef = useRef(null); const stopsListRef = useRef(null); const railRef = useRef(null); // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée) const mock = { time: '16h44', status: "à l'heure", trainType: 'Fluo', trainNumber: '891811', destinationBig: 'Seurre', destinationStation: '', stops: [ 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse' ], }; // état pour l'horaire sélectionné const [selectedHoraire, setSelectedHoraire] = useState(null); const [loadingHoraire, setLoadingHoraire] = useState(false); const [horaireError, setHoraireError] = useState(null); const [candidatesCount, setCandidatesCount] = useState(0); // perturbations/travaux (récupérées depuis Supabase) const [perturbations, setPerturbations] = useState([]); const [, setTravaux] = useState([]); // helpers locaux (simples) — similaires à ceux du composant principal const normalizeName = useCallback((s) => { if (!s) return ''; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); } }, []); 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; }, []); const formatTimeNoSeconds = useCallback((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'); }, []); // ...existing code... 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}`; }, []); // lecture des query params pour gare & quai const paramsForThis = new URLSearchParams(window.location.search || ''); const gareParam = paramsForThis.get('gare'); const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId'); const quaiParam = paramsForThis.get('quai'); const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true'); const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs) const getStops = (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; if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','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 []; }; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) --- const safeParseArrets = (p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return []; if (Array.isArray(raw)) return raw; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed.arrets)) return parsed.arrets; return parsed; } return []; } catch (e) { return []; } }; const parsePerturbArretsObject = (p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return { arrets: [], jours: null }; if (typeof raw === 'object') { if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null }; if (Array.isArray(raw)) return { arrets: raw, jours: null }; return { arrets: [], jours: null }; } try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null }; } return { arrets: [], jours: null }; } catch (e) { return { arrets: [], jours: null }; } }; const getPerturbationJours = useCallback((p) => { if (!p) return null; const candidate = p.jours_perturbation || p.jours || null; const result = new Set(); const pushDate = (d) => { if (!d) return; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){} }; if (candidate) { if (Array.isArray(candidate)) candidate.forEach(item => pushDate(item.date || item)); else if (typeof candidate === 'string') { try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(candidate); } } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); else pushDate(candidate.date || null); } } try { const parsedObj = parsePerturbArretsObject(p); const jp = parsedObj.jours; if (jp) { if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); else if (typeof jp === 'string') { try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } } else if (typeof jp === 'object') pushDate(jp.date || null); } } catch (e) {} return result.size ? result : null; }, [parsePerturbArretsObject]); const parseDelayMinutesFromArret = (arret) => { if (!arret) return null; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min']; for (const k of keys) { if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n); } } return null; }; const buildPerturbMap = useCallback((list = []) => { 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); } }); return idx; }, [normalizeName]); const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) => { const res = []; const map = idx || buildPerturbMap(perturbations || []); 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)); 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) {} } return Array.from(new Set(res)); }; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; 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; for (const p of found) { try { if (horaireYmd) { const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); 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) { if (!joursSet.has(horaireYmd)) continue; } } catch (e) {} const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; const arrets = parsedObj.arrets || []; let matched = false; for (const a of arrets) { 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; const na = normalizeName(aName || ''); const ns = normalizeName(stationName || ''); const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { matched = true; const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } if (!matched) { 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; if (appliesByRef || appliesByNumber || isGlobal) { const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } } if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; return { label: "à l'heure", detail: '', delayMinutes: null }; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours]); // Fetch perturbations & travaux from Supabase periodically useEffect(() => { let mounted = true; let inFlight = false; const fetchPerturb = async () => { if (!mounted) return; if (inFlight) return; inFlight = true; try { if (!supabase) return; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') ]); if (!mounted) return; if (pError || tError) { // ignore quietly — keep previous data } else { // Filtrer les perturbations pour ne conserver que celles valides aujourd'hui const todayYmd = new Date().toLocaleDateString('en-CA'); 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 || p.start_date || p.date_debut); const db = toYMD(p.date_debut || p.start_date); const df = toYMD(p.date_fin || p.end_date); if (pDate && pDate !== todayYmd) return false; if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(todayYmd)) return false; } catch (e) {} return true; } catch (e) { return true; } }); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); } } catch (e) { // ignore } finally { inFlight = false; } }; fetchPerturb(); const id = setInterval(fetchPerturb, 15000); return () => { mounted = false; clearInterval(id); }; }, []); // Annotate selectedHoraire with computed status when perturbations change useEffect(() => { if (!selectedHoraire || !selectedHoraire.horaire) return; const horaire = selectedHoraire.horaire; const stop = selectedHoraire.stop || null; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null; try { const map = buildPerturbMap(perturbations || []); const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null); // keep original scheduled time for display (_displayTime) const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure); const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null)); // compute updated time (delayed) but store separately — do not override the original displayed time let updatedTime = null; if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') { updatedTime = formatTimeFromHM(originalBase + status.delayMinutes); } setSelectedHoraire(prev => ({ ...(prev||{}), horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime })); } catch (e) { /* ignore */ } }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]); // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai) useEffect(() => { let mounted = true; async function loadNextForQuai() { setLoadingHoraire(true); setHoraireError(null); setSelectedHoraire(null); if (!supabase) { setHoraireError('Supabase non initialisé'); setLoadingHoraire(false); return; } try { const { data: all, error } = await supabase.from('horaires').select('id, *'); if (error) { if (mounted) setHoraireError(error.message || String(error)); setLoadingHoraire(false); return; } if (!mounted) return; if (!Array.isArray(all) || all.length === 0) { setLoadingHoraire(false); return; } const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null; const now = new Date(); const nowHM = now.getHours()*60 + now.getMinutes(); const candidates = []; for (const h of all) { if (!h) continue; // build stops list (support multiple field names) const stops = getStops(h); // compute max index for terminus detection if stops contain index let maxIdx = null; if (Array.isArray(stops) && stops.length) { for (const st of stops) { 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); } } } // Match stop by name or id (prefer stop entry over horaire-level arrival) let stopMatch = null; if (Array.isArray(stops) && stops.length) { // first try to match by id if gareIdParam present if (gareIdParam) { const gid = String(gareIdParam); for (const s of stops) { const sid = s && (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null); if (sid != null && String(sid) === gid) { stopMatch = s; break; } } } // then try to match by normalized name if (!stopMatch && normalizedTarget) { for (const s of stops) { const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || s.station_name || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { stopMatch = s; break; } } } } // if no stopMatch found in stops, check horaire-level gare_arrivee (arrival terminus) if (!stopMatch) { const arriveeName = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || ''); if (arriveeName && normalizedTarget && (arriveeName === normalizedTarget || arriveeName.includes(normalizedTarget) || normalizedTarget.includes(arriveeName))) { stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee, gare_id: h.gare_arrivee_id || h.gare_arrivee }; } } if (!stopMatch) continue; // does not arrive at the targeted station // determine platform/quai for this stop/horaire const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,''); // Only include horaires that have a platform/quai for the displayed station if (!normPlatform) continue; if (targetQuai) { if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue; } // ensure we have an arrival time const stopIsObject = stopMatch && typeof stopMatch === 'object'; const hasArrival = stopIsObject && ( typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined' ); if (stopIsObject && !hasArrival) continue; // pick time (arrival) from stopMatch or fallback to horaire-level arrival const rawTime = (hasArrival ? (stopMatch.heure_arrivee || stopMatch.time || stopMatch.heure || stopMatch.arrival_time || stopMatch.arrival) : (h.heure_arrivee || h.time || h.heure || h.arrival_time || h.arrivee)); const depHM = parseTimeHM(rawTime); if (depHM == null) continue; // compute minutes until arrival (allow next-day wrap) let minutesUntil = depHM - nowHM; if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime }); } if (!candidates.length) { setCandidatesCount(0); setLoadingHoraire(false); setSelectedHoraire(null); return; } // split into today (depHM > nowHM) and tomorrow (depHM <= nowHM) const todayCandidates = candidates.filter(c => c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); const tomorrowCandidates = candidates.filter(c => c.depHM <= nowHM).sort((a,b) => a.depHM - b.depHM); let chosen = null; if (todayCandidates.length > 0) chosen = todayCandidates[0]; else if (tomorrowCandidates.length > 0) chosen = tomorrowCandidates[0]; if (mounted) { setCandidatesCount(candidates.length); setSelectedHoraire(chosen || null); } } catch (err) { console.error('Erreur supabase quai', err); if (mounted) setHoraireError(err.message || String(err)); } finally { if (mounted) setLoadingHoraire(false); } } loadNextForQuai(); return () => { mounted = false; }; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM]); // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present) const [showTime, setShowTime] = useState(true); // derive a simple status label string to use in the dependency array (avoids complex expressions) const _selectedStatusLabel = (() => { const horaireObj = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const computedLabel = horaireObj && horaireObj._status && horaireObj._status.label ? String(horaireObj._status.label) : null; const rawStatus = horaireObj && horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status && horaireObj.status.label ? String(horaireObj.status.label) : null)) : null; return String(computedLabel || rawStatus || mock.status || '').toLowerCase(); })(); useEffect(()=>{ let id = null; if (/retard|supprim/.test(_selectedStatusLabel)) { // ensure we start by showing the time setShowTime(true); id = setInterval(() => setShowTime(prev => !prev), 3000); } else { setShowTime(true); } return () => { if (id) clearInterval(id); }; }, [_selectedStatusLabel, mock.status]); // compute and position the rail so it covers from first stop center to last stop center useEffect(() => { function updateRail(){ const rail = railRef.current; const list = stopsListRef.current; const right = rightRef.current; if(!rail || !list || !right) return; const items = Array.from(list.querySelectorAll('li')); if(items.length === 0) return; const first = items[0]; // find destination (last with dest class) or fallback to last item const last = items.find(el => el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1]; const rightRect = right.getBoundingClientRect(); const firstRect = first.getBoundingClientRect(); const lastRect = last.getBoundingClientRect(); const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top; // add small padding so rail extends a bit beyond centers const pad = 9; const top = Math.max(0, firstCenterY - pad); const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2); // compute horizontal position so dots are centered next to the text const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text'); const textRect = firstText ? firstText.getBoundingClientRect() : firstRect; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px const railWidth = 35; const left = centerX - railWidth/2; // apply styles for the rail rectangle rail.style.top = `${Math.round(top)}px`; rail.style.height = `${Math.round(height)}px`; rail.style.left = `${Math.round(left)}px`; rail.style.width = `${railWidth}px`; // remove background-image reliance and render individual dots as DOM children // clear existing dots while (rail.firstChild) rail.removeChild(rail.firstChild); // create a dot element for each item and position it exactly at the item's vertical center const railWidthComputed = parseInt(rail.style.width, 8) || railWidth; items.forEach(it => { const r = it.getBoundingClientRect(); const centerY = r.top + r.height/2 - (rightRect.top + top); const dot = document.createElement('span'); dot.className = 'aff-quais__rail-dot'; dot.style.position = 'absolute'; dot.style.left = '50%'; dot.style.transform = 'translate(-50%, -50%)'; dot.style.top = `${Math.round(centerY)}px`; // set dot diameter to rail width dot.style.width = `${railWidthComputed}px`; dot.style.height = `${railWidthComputed}px`; dot.style.borderRadius = '50%'; dot.style.background = '#ffe600'; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset'; rail.appendChild(dot); }); } updateRail(); window.addEventListener('resize', updateRail); const obs = new MutationObserver(updateRail); const listNode = stopsListRef.current; if(listNode) obs.observe(listNode, { childList:true, subtree:true }); return ()=>{ window.removeEventListener('resize', updateRail); if(obs) obs.disconnect(); }; }, [selectedHoraire]); // Observer changement des stops via selectedHoraire // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent) const displayTimeOrStatus = () => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status; const statusObj = horaire && horaire._status ? horaire._status : null; // prefer precomputed _displayTime if available const time = selectedHoraire && selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time); // build canonical status label when we have computed status object let canonicalStatus = null; if (statusObj) { if (typeof statusObj.delayMinutes === 'number' && !Number.isNaN(statusObj.delayMinutes)) { canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`; } else if (statusObj.label && String(statusObj.label).toLowerCase().includes('supprim')) { canonicalStatus = 'supprimé'; } else if (statusObj.label) { canonicalStatus = String(statusObj.label); } } const s = (canonicalStatus || statusStrFromField || '').toLowerCase(); const isAlert = s.includes('retard') || s.includes('supprim'); const label = canonicalStatus || statusStrFromField || ''; // Always render both time and status inside a fixed-size wrapper to avoid layout shift return ( <span className="aff-quais__time-status-wrapper" aria-live="polite"> <span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}>{time}</span> <span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime && isAlert) ? 'visible' : 'hidden'}`}>{label}</span> </span> ); } return ( <div className="aff-quais__container aff-quais__container--strict aff-quais__container--arrivees"> <div className="aff-quais__left aff-quais__left--strict"> {/* logo (depuis le type de train) */} {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; return <img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className="aff-quais__type-logo--strict" />; })()} <div className="aff-quais__watermark" aria-hidden>départ</div> {/* affichage des états de chargement / erreur pour debug */} {horaireError ? <div className="aff-quais__error" style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}>{horaireError}</div> : null} {loadingHoraire ? <div className="aff-quais__loading" style={{color:'#666',fontSize:'0.85rem',marginTop:6}}>Chargement...</div> : null} {debugEnabled ? ( <div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}> <div>Candidates: {candidatesCount}</div> {selectedHoraire ? <pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}>{JSON.stringify(selectedHoraire, null, 2)}</pre> : null} </div> ) : null} {/* logo du type en haut à droite */} <div className="aff-quais__row aff-quais__row--time-status"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const status = horaire ? (horaire.status || (horaire._status && horaire._status.label) || mock.status) : mock.status; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim'); if (isAlert) return displayTimeOrStatus(); return ( <> <span className="aff-quais__time aff-quais__time--strict">{time}</span> <span className="aff-quais__time-status aff-quais__time-status--strict">{status}</span> </> ); })()} </div> <div className="aff-quais__destination aff-quais__destination--strict"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const stop = selectedHoraire && selectedHoraire.stop ? selectedHoraire.stop : null; // For arrivals: show the origin of the train (gare_depart_*) as the main station label const destinationBig = horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || '') : mock.destinationBig; const rawDestinationStation = stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation); return ( <> <div className="aff-quais__destination-line1 aff-quais__destination-line1--strict">{destinationBig}</div> {(rawDestinationStation && destinationBig && normalizeName(rawDestinationStation) !== normalizeName(destinationBig)) ? (<div className="aff-quais__destination-line2 aff-quais__destination-line2--strict">{rawDestinationStation}</div>) : null} </> ); })()} </div> <div className="aff-quais__train-id aff-quais__train-id--strict">{(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; return (<>{trainType}&nbsp;&nbsp;<span className="aff-quais__train-number--strict">{trainNumber}</span></>); })()}</div> </div> <div className="aff-quais__separator--strict" aria-hidden></div> <div className="aff-quais__right aff-quais__right--strict" ref={rightRef}> {/* barre verticale de points (élément explicite pour fiabilité) */} <div className="aff-quais__stops-rail" aria-hidden ref={railRef} /> <ul className="aff-quais__stops-list--strict" ref={stopsListRef}> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; // utiliser la helper getStops pour supporter plusieurs formats let stops = horaire ? getStops(horaire) : []; if (!Array.isArray(stops) || stops.length === 0) { // fallback parsing si la colonne est une chaîne JSON if (horaire && typeof horaire.stops === 'string') { try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } } } if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; // construire une liste propre en extrayant un label pour chaque stop const labeled = stops.map((stop, idx) => { let label = ''; if (typeof stop === 'string') label = stop; else if (stop && typeof stop === 'object') { // vérifier plusieurs clés possibles label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || ''; // si l'objet possède une clé 'stop' qui est elle-même un objet if (!label && stop.stop && typeof stop.stop === 'object') { label = stop.stop.name || stop.stop.stop_name || ''; } } label = String(label || '').trim(); return { label, original: stop, idx }; }).filter(it => it.label); if (labeled.length === 0) return null; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop) let matchedIndex = -1; try { if (selectedHoraire && selectedHoraire.stop) { const target = selectedHoraire.stop; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || '')); const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null; for (let i = 0; i < stops.length; i++) { const s = stops[i]; // compare by id first if available if (targetId != null) { const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null; if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; } } const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || ''); if (name && targetName && name === targetName) { matchedIndex = i; break; } } } } catch (e) { /* ignore */ } // For arrivals: show the stops served BEFORE the display station (i.e. previous stops) // If we have a matchedIndex, keep only items with idx < matchedIndex. // Display stops in the reverse direction relative to the route: show the farthest previous stop first let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx < matchedIndex) : true); // sort ascending so the oldest (farthest) previous stop appears first -> reverse of travel direction subsequent = subsequent.sort((a,b) => a.idx - b.idx); // Remove consecutive duplicate labels (case-insensitive) to avoid repeated identical station names if (subsequent.length > 1) { const collapsed = []; for (const it of subsequent) { const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null; const cur = it.label ? String(it.label).trim().toLowerCase() : ''; if (cur && prev && prev === cur) { // skip duplicate consecutive entry continue; } collapsed.push(it); } subsequent = collapsed; } // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue if (subsequent.length === 0) return null; return subsequent.map((item, mapIndex) => { const isLast = item.idx === (stops.length - 1); return ( <li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}> <span className="aff-quais__stop-bullet--strict" /> <span className="aff-quais__stop-text--strict">{item.label}</span> </li> ); }); })()} </ul> </div> {/* footer band same as classic departures with clock on the right */} <div className="aff-quais__footer" aria-hidden> <div className="aff-quais__footer-inner"> <div className="aff-quais__footer-left"></div> <div className="aff-quais__footer-right"><Clock /></div> </div> </div> </div> ); } " />
|
|
<option name="updatedContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react'; import './ClassiquesArriveesQuais.css'; import '../ClassiquesArrivees.css'; /* ClassiquesArriveesQuais.js Variante "Arrivées - quais" du composant quais. Différences principales avec la version départs: - utilise `heure_arrivee` / `gare_arrivee_*` lorsque disponible pour afficher l'heure et la destination - watermark affiché: "arrivée" - styles importés depuis `ClassiquesArriveesQuais.css` qui reprend la palette de `ClassiquesArrivees.css` Champs utilisés pour le calcul/affichage: - horaire.heure_arrivee, horaire.time, horaire.heure - horaire.gare_arrivee_nom, horaire.gare_arrivee - stop.heure_arrivee, stop.gare_nom, stop.gare_id - perturbations: mêmes clés que pour les départs (retard, retard_minutes, perturbations_arrets, etc.) */ import supabase from '../../../../lib/supabase'; // Hook pour récupérer la map des logos depuis /type/data.json function useTypeLogos(){ const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' }); useEffect(() => { let mounted = true; const load = async () => { const candidates = ['/type/quais/data-color.json', '/type/data.json']; for(const url of candidates){ try{ const res = await fetch(url); if(!res.ok) continue; const data = await res.json(); if(!mounted) return; const logos = {}; if(Array.isArray(data.logos)){ data.logos.forEach(item => { // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path let file = item.file; if(!file && item.path){ const parts = String(item.path).split('/'); file = parts[parts.length-1]; } if(!file) return; const basename = file.replace(/\.[^/.]+$/, ''); const semantic = basename.replace(/^logo-/, ''); const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-'); // Prefer color logos in the 'quais' subfolder if data came from the quais file if(url.includes('/quais/')){ logos[key] = `/type/quais/${file}`; } else { logos[key] = `/type/${file}`; } }); } logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png'; setMap(logos); return; }catch(e){ // try next candidate (ignore error and continue loop) } } // no candidate succeeded — keep fallback }; load(); return () => { mounted = false; }; }, []); return map; } function getTypeLogo(type, TYPE_LOGOS_MAP){ const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' }; if(!type) return map.default; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-'); if(map[norm]) return map[norm]; const keys = Object.keys(map).filter(k => k !== 'default'); for(const k of keys){ if(k.includes(norm) || norm.includes(k)) return map[k]; } return map.default; } 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-quais__clock"> <div className="aff-quais__clock-box"><span className="aff-quais__clock-main">{hh}:{mm}:{ss}</span></div> </div> ); } export default function ClassiquesArriveesQuais(){ const typeLogos = useTypeLogos(); const rightRef = useRef(null); const stopsListRef = useRef(null); const railRef = useRef(null); // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée) const mock = { time: '16h44', status: "à l'heure", trainType: 'Fluo', trainNumber: '891811', destinationBig: 'Seurre', destinationStation: '', stops: [ 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse' ], }; // état pour l'horaire sélectionné const [selectedHoraire, setSelectedHoraire] = useState(null); const [loadingHoraire, setLoadingHoraire] = useState(false); const [horaireError, setHoraireError] = useState(null); const [candidatesCount, setCandidatesCount] = useState(0); // perturbations/travaux (récupérées depuis Supabase) const [perturbations, setPerturbations] = useState([]); const [, setTravaux] = useState([]); // helpers locaux (simples) — similaires à ceux du composant principal const normalizeName = useCallback((s) => { if (!s) return ''; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); } }, []); 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; }, []); const formatTimeNoSeconds = useCallback((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'); }, []); // ...existing code... 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}`; }, []); // lecture des query params pour gare & quai const paramsForThis = new URLSearchParams(window.location.search || ''); const gareParam = paramsForThis.get('gare'); const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId'); const quaiParam = paramsForThis.get('quai'); const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true'); const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs) const getStops = (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; if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','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 []; }; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) --- const safeParseArrets = (p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return []; if (Array.isArray(raw)) return raw; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed.arrets)) return parsed.arrets; return parsed; } return []; } catch (e) { return []; } }; const parsePerturbArretsObject = useCallback((p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return { arrets: [], jours: null }; if (typeof raw === 'object') { if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null }; if (Array.isArray(raw)) return { arrets: raw, jours: null }; return { arrets: [], jours: null }; } try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null }; } return { arrets: [], jours: null }; } catch (e) { return { arrets: [], jours: null }; } }, []); const getPerturbationJours = useCallback((p) => { if (!p) return null; const candidate = p.jours_perturbation || p.jours || null; const result = new Set(); const pushDate = (d) => { if (!d) return; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){} }; if (candidate) { if (Array.isArray(candidate)) candidate.forEach(item => pushDate(item.date || item)); else if (typeof candidate === 'string') { try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(candidate); } } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); else pushDate(candidate.date || null); } } try { const parsedObj = parsePerturbArretsObject(p); const jp = parsedObj.jours; if (jp) { if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); else if (typeof jp === 'string') { try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } } else if (typeof jp === 'object') pushDate(jp.date || null); } } catch (e) {} return result.size ? result : null; }, [parsePerturbArretsObject]); const parseDelayMinutesFromArret = (arret) => { if (!arret) return null; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min']; for (const k of keys) { if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n); } } return null; }; const buildPerturbMap = useCallback((list = []) => { 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); } }); return idx; }, [normalizeName]); const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) => { const res = []; const map = idx || buildPerturbMap(perturbations || []); 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)); 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) {} } return Array.from(new Set(res)); }; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; 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; for (const p of found) { try { if (horaireYmd) { const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); 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) { if (!joursSet.has(horaireYmd)) continue; } } catch (e) {} const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; const arrets = parsedObj.arrets || []; let matched = false; for (const a of arrets) { 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; const na = normalizeName(aName || ''); const ns = normalizeName(stationName || ''); const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { matched = true; const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } if (!matched) { 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; if (appliesByRef || appliesByNumber || isGlobal) { const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } } if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; return { label: "à l'heure", detail: '', delayMinutes: null }; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName]); // Fetch perturbations & travaux from Supabase periodically useEffect(() => { let mounted = true; let inFlight = false; const fetchPerturb = async () => { if (!mounted) return; if (inFlight) return; inFlight = true; try { if (!supabase) return; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') ]); if (!mounted) return; if (pError || tError) { // ignore quietly — keep previous data } else { // Filtrer les perturbations pour ne conserver que celles valides aujourd'hui const todayYmd = new Date().toLocaleDateString('en-CA'); 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 || p.start_date || p.date_debut); const db = toYMD(p.date_debut || p.start_date); const df = toYMD(p.date_fin || p.end_date); if (pDate && pDate !== todayYmd) return false; if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(todayYmd)) return false; } catch (e) {} return true; } catch (e) { return true; } }); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); } } catch (e) { // ignore } finally { inFlight = false; } }; fetchPerturb(); const id = setInterval(fetchPerturb, 15000); return () => { mounted = false; clearInterval(id); }; }, [getPerturbationJours]); // Annotate selectedHoraire with computed status when perturbations change useEffect(() => { if (!selectedHoraire || !selectedHoraire.horaire) return; const horaire = selectedHoraire.horaire; const stop = selectedHoraire.stop || null; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null; try { const map = buildPerturbMap(perturbations || []); const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null); // keep original scheduled time for display (_displayTime) const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure); const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null)); // compute updated time (delayed) but store separately — do not override the original displayed time let updatedTime = null; if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') { updatedTime = formatTimeFromHM(originalBase + status.delayMinutes); } setSelectedHoraire(prev => ({ ...(prev||{}), horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime })); } catch (e) { /* ignore */ } }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]); // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai) useEffect(() => { let mounted = true; async function loadNextForQuai() { setLoadingHoraire(true); setHoraireError(null); setSelectedHoraire(null); if (!supabase) { setHoraireError('Supabase non initialisé'); setLoadingHoraire(false); return; } try { const { data: all, error } = await supabase.from('horaires').select('id, *'); if (error) { if (mounted) setHoraireError(error.message || String(error)); setLoadingHoraire(false); return; } if (!mounted) return; if (!Array.isArray(all) || all.length === 0) { setLoadingHoraire(false); return; } const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null; const now = new Date(); const nowHM = now.getHours()*60 + now.getMinutes(); const candidates = []; for (const h of all) { if (!h) continue; // build stops list (support multiple field names) const stops = getStops(h); // compute max index for terminus detection if stops contain index let maxIdx = null; if (Array.isArray(stops) && stops.length) { for (const st of stops) { 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); } } } // Match stop by name or id (prefer stop entry over horaire-level arrival) let stopMatch = null; if (Array.isArray(stops) && stops.length) { // first try to match by id if gareIdParam present if (gareIdParam) { const gid = String(gareIdParam); for (const s of stops) { const sid = s && (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null); if (sid != null && String(sid) === gid) { stopMatch = s; break; } } } // then try to match by normalized name if (!stopMatch && normalizedTarget) { for (const s of stops) { const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || s.station_name || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { stopMatch = s; break; } } } } // if no stopMatch found in stops, check horaire-level gare_arrivee (arrival terminus) if (!stopMatch) { const arriveeName = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || ''); if (arriveeName && normalizedTarget && (arriveeName === normalizedTarget || arriveeName.includes(normalizedTarget) || normalizedTarget.includes(arriveeName))) { stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee, gare_id: h.gare_arrivee_id || h.gare_arrivee }; } } if (!stopMatch) continue; // does not arrive at the targeted station // determine platform/quai for this stop/horaire const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,''); // Only include horaires that have a platform/quai for the displayed station if (!normPlatform) continue; if (targetQuai) { if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue; } // ensure we have an arrival time const stopIsObject = stopMatch && typeof stopMatch === 'object'; const hasArrival = stopIsObject && ( typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined' ); if (stopIsObject && !hasArrival) continue; // pick time (arrival) from stopMatch or fallback to horaire-level arrival const rawTime = (hasArrival ? (stopMatch.heure_arrivee || stopMatch.time || stopMatch.heure || stopMatch.arrival_time || stopMatch.arrival) : (h.heure_arrivee || h.time || h.heure || h.arrival_time || h.arrivee)); const depHM = parseTimeHM(rawTime); if (depHM == null) continue; // compute minutes until arrival (allow next-day wrap) let minutesUntil = depHM - nowHM; if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime }); } if (!candidates.length) { setCandidatesCount(0); setLoadingHoraire(false); setSelectedHoraire(null); return; } // split into today (depHM > nowHM) and tomorrow (depHM <= nowHM) const todayCandidates = candidates.filter(c => c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); const tomorrowCandidates = candidates.filter(c => c.depHM <= nowHM).sort((a,b) => a.depHM - b.depHM); let chosen = null; if (todayCandidates.length > 0) chosen = todayCandidates[0]; else if (tomorrowCandidates.length > 0) chosen = tomorrowCandidates[0]; if (mounted) { setCandidatesCount(candidates.length); setSelectedHoraire(chosen || null); } } catch (err) { console.error('Erreur supabase quai', err); if (mounted) setHoraireError(err.message || String(err)); } finally { if (mounted) setLoadingHoraire(false); } } loadNextForQuai(); return () => { mounted = false; }; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName]); // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present) const [showTime, setShowTime] = useState(true); // derive a simple status label string to use in the dependency array (avoids complex expressions) const _selectedStatusLabel = (() => { const horaireObj = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const computedLabel = horaireObj && horaireObj._status && horaireObj._status.label ? String(horaireObj._status.label) : null; const rawStatus = horaireObj && horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status && horaireObj.status.label ? String(horaireObj.status.label) : null)) : null; return String(computedLabel || rawStatus || mock.status || '').toLowerCase(); })(); useEffect(()=>{ let id = null; if (/retard|supprim/.test(_selectedStatusLabel)) { // ensure we start by showing the time setShowTime(true); id = setInterval(() => setShowTime(prev => !prev), 3000); } else { setShowTime(true); } return () => { if (id) clearInterval(id); }; }, [_selectedStatusLabel, mock.status]); // compute and position the rail so it covers from first stop center to last stop center useEffect(() => { function updateRail(){ const rail = railRef.current; const list = stopsListRef.current; const right = rightRef.current; if(!rail || !list || !right) return; const items = Array.from(list.querySelectorAll('li')); if(items.length === 0) return; const first = items[0]; // find destination (last with dest class) or fallback to last item const last = items.find(el => el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1]; const rightRect = right.getBoundingClientRect(); const firstRect = first.getBoundingClientRect(); const lastRect = last.getBoundingClientRect(); const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top; // add small padding so rail extends a bit beyond centers const pad = 9; const top = Math.max(0, firstCenterY - pad); const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2); // compute horizontal position so dots are centered next to the text const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text'); const textRect = firstText ? firstText.getBoundingClientRect() : firstRect; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px const railWidth = 35; const left = centerX - railWidth/2; // apply styles for the rail rectangle rail.style.top = `${Math.round(top)}px`; rail.style.height = `${Math.round(height)}px`; rail.style.left = `${Math.round(left)}px`; rail.style.width = `${railWidth}px`; // remove background-image reliance and render individual dots as DOM children // clear existing dots while (rail.firstChild) rail.removeChild(rail.firstChild); // create a dot element for each item and position it exactly at the item's vertical center const railWidthComputed = parseInt(rail.style.width, 8) || railWidth; items.forEach(it => { const r = it.getBoundingClientRect(); const centerY = r.top + r.height/2 - (rightRect.top + top); const dot = document.createElement('span'); dot.className = 'aff-quais__rail-dot'; dot.style.position = 'absolute'; dot.style.left = '50%'; dot.style.transform = 'translate(-50%, -50%)'; dot.style.top = `${Math.round(centerY)}px`; // set dot diameter to rail width dot.style.width = `${railWidthComputed}px`; dot.style.height = `${railWidthComputed}px`; dot.style.borderRadius = '50%'; dot.style.background = '#ffe600'; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset'; rail.appendChild(dot); }); } updateRail(); window.addEventListener('resize', updateRail); const obs = new MutationObserver(updateRail); const listNode = stopsListRef.current; if(listNode) obs.observe(listNode, { childList:true, subtree:true }); return ()=>{ window.removeEventListener('resize', updateRail); if(obs) obs.disconnect(); }; }, [selectedHoraire]); // Observer changement des stops via selectedHoraire // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent) const displayTimeOrStatus = () => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status; const statusObj = horaire && horaire._status ? horaire._status : null; // prefer precomputed _displayTime if available const time = selectedHoraire && selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time); // build canonical status label when we have computed status object let canonicalStatus = null; if (statusObj) { if (typeof statusObj.delayMinutes === 'number' && !Number.isNaN(statusObj.delayMinutes)) { canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`; } else if (statusObj.label && String(statusObj.label).toLowerCase().includes('supprim')) { canonicalStatus = 'supprimé'; } else if (statusObj.label) { canonicalStatus = String(statusObj.label); } } const s = (canonicalStatus || statusStrFromField || '').toLowerCase(); const isAlert = s.includes('retard') || s.includes('supprim'); const label = canonicalStatus || statusStrFromField || ''; // Always render both time and status inside a fixed-size wrapper to avoid layout shift return ( <span className="aff-quais__time-status-wrapper" aria-live="polite"> <span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}>{time}</span> <span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime && isAlert) ? 'visible' : 'hidden'}`}>{label}</span> </span> ); } return ( <div className="aff-quais__container aff-quais__container--strict aff-quais__container--arrivees"> <div className="aff-quais__left aff-quais__left--strict"> {/* logo (depuis le type de train) */} {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; return <img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className="aff-quais__type-logo--strict" />; })()} <div className="aff-quais__watermark" aria-hidden>départ</div> {/* affichage des états de chargement / erreur pour debug */} {horaireError ? <div className="aff-quais__error" style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}>{horaireError}</div> : null} {loadingHoraire ? <div className="aff-quais__loading" style={{color:'#666',fontSize:'0.85rem',marginTop:6}}>Chargement...</div> : null} {debugEnabled ? ( <div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}> <div>Candidates: {candidatesCount}</div> {selectedHoraire ? <pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}>{JSON.stringify(selectedHoraire, null, 2)}</pre> : null} </div> ) : null} {/* logo du type en haut à droite */} <div className="aff-quais__row aff-quais__row--time-status"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const status = horaire ? (horaire.status || (horaire._status && horaire._status.label) || mock.status) : mock.status; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim'); if (isAlert) return displayTimeOrStatus(); return ( <> <span className="aff-quais__time aff-quais__time--strict">{time}</span> <span className="aff-quais__time-status aff-quais__time-status--strict">{status}</span> </> ); })()} </div> <div className="aff-quais__destination aff-quais__destination--strict"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const stop = selectedHoraire && selectedHoraire.stop ? selectedHoraire.stop : null; // For arrivals: show the origin of the train (gare_depart_*) as the main station label const destinationBig = horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || '') : mock.destinationBig; const rawDestinationStation = stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation); return ( <> <div className="aff-quais__destination-line1 aff-quais__destination-line1--strict">{destinationBig}</div> {(rawDestinationStation && destinationBig && normalizeName(rawDestinationStation) !== normalizeName(destinationBig)) ? (<div className="aff-quais__destination-line2 aff-quais__destination-line2--strict">{rawDestinationStation}</div>) : null} </> ); })()} </div> <div className="aff-quais__train-id aff-quais__train-id--strict">{(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; return (<>{trainType}&nbsp;&nbsp;<span className="aff-quais__train-number--strict">{trainNumber}</span></>); })()}</div> </div> <div className="aff-quais__separator--strict" aria-hidden></div> <div className="aff-quais__right aff-quais__right--strict" ref={rightRef}> {/* barre verticale de points (élément explicite pour fiabilité) */} <div className="aff-quais__stops-rail" aria-hidden ref={railRef} /> <ul className="aff-quais__stops-list--strict" ref={stopsListRef}> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; // utiliser la helper getStops pour supporter plusieurs formats let stops = horaire ? getStops(horaire) : []; if (!Array.isArray(stops) || stops.length === 0) { // fallback parsing si la colonne est une chaîne JSON if (horaire && typeof horaire.stops === 'string') { try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } } } if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; // construire une liste propre en extrayant un label pour chaque stop const labeled = stops.map((stop, idx) => { let label = ''; if (typeof stop === 'string') label = stop; else if (stop && typeof stop === 'object') { // vérifier plusieurs clés possibles label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || ''; // si l'objet possède une clé 'stop' qui est elle-même un objet if (!label && stop.stop && typeof stop.stop === 'object') { label = stop.stop.name || stop.stop.stop_name || ''; } } label = String(label || '').trim(); return { label, original: stop, idx }; }).filter(it => it.label); if (labeled.length === 0) return null; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop) let matchedIndex = -1; try { if (selectedHoraire && selectedHoraire.stop) { const target = selectedHoraire.stop; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || '')); const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null; for (let i = 0; i < stops.length; i++) { const s = stops[i]; // compare by id first if available if (targetId != null) { const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null; if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; } } const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || ''); if (name && targetName && name === targetName) { matchedIndex = i; break; } } } } catch (e) { /* ignore */ } // For arrivals: show the stops served BEFORE the display station (i.e. previous stops) // If we have a matchedIndex, keep only items with idx < matchedIndex. // Display stops in the reverse direction relative to the route: show the farthest previous stop first let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx < matchedIndex) : true); // sort ascending so the oldest (farthest) previous stop appears first -> reverse of travel direction subsequent = subsequent.sort((a,b) => a.idx - b.idx); // Remove consecutive duplicate labels (case-insensitive) to avoid repeated identical station names if (subsequent.length > 1) { const collapsed = []; for (const it of subsequent) { const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null; const cur = it.label ? String(it.label).trim().toLowerCase() : ''; if (cur && prev && prev === cur) { // skip duplicate consecutive entry continue; } collapsed.push(it); } subsequent = collapsed; } // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue if (subsequent.length === 0) return null; return subsequent.map((item, mapIndex) => { const isLast = item.idx === (stops.length - 1); return ( <li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}> <span className="aff-quais__stop-bullet--strict" /> <span className="aff-quais__stop-text--strict">{item.label}</span> </li> ); }); })()} </ul> </div> {/* footer band same as classic departures with clock on the right */} <div className="aff-quais__footer" aria-hidden> <div className="aff-quais__footer-inner"> <div className="aff-quais__footer-left"></div> <div className="aff-quais__footer-right"><Clock /></div> </div> </div> </div> ); } " />
|
|
</PendingDiffInfo>
|
|
</value>
|
|
</entry>
|
|
<entry key="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js">
|
|
<value>
|
|
<PendingDiffInfo>
|
|
<option name="filePath" value="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js" />
|
|
<option name="originalContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react'; import './ClassiquesDepartsQuais.css'; import supabase from '../../../../lib/supabase'; // Hook pour récupérer la map des logos depuis /type/data.json function useTypeLogos(){ const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' }); useEffect(() => { let mounted = true; const load = async () => { const candidates = ['/type/quais/data-color.json', '/type/data.json']; for(const url of candidates){ try{ const res = await fetch(url); if(!res.ok) continue; const data = await res.json(); if(!mounted) return; const logos = {}; if(Array.isArray(data.logos)){ data.logos.forEach(item => { // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path let file = item.file; if(!file && item.path){ const parts = String(item.path).split('/'); file = parts[parts.length-1]; } if(!file) return; const basename = file.replace(/\.[^/.]+$/, ''); const semantic = basename.replace(/^logo-/, ''); const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-'); // Prefer color logos in the 'quais' subfolder if data came from the quais file if(url.includes('/quais/')){ logos[key] = `/type/quais/${file}`; } else { logos[key] = `/type/${file}`; } }); } logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png'; setMap(logos); return; }catch(e){ // try next candidate (ignore error and continue loop) } } // no candidate succeeded — keep fallback }; load(); return () => { mounted = false; }; }, []); return map; } function getTypeLogo(type, TYPE_LOGOS_MAP){ const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' }; if(!type) return map.default; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-'); if(map[norm]) return map[norm]; const keys = Object.keys(map).filter(k => k !== 'default'); for(const k of keys){ if(k.includes(norm) || norm.includes(k)) return map[k]; } return map.default; } 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-quais__clock"> <div className="aff-quais__clock-box"><span className="aff-quais__clock-main">{hh}:{mm}:{ss}</span></div> </div> ); } export default function ClassiquesDepartsQuais(){ const typeLogos = useTypeLogos(); const rightRef = useRef(null); const stopsListRef = useRef(null); const railRef = useRef(null); // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée) const mock = { time: '16h44', status: "à l'heure", trainType: 'Fluo', trainNumber: '891811', destinationBig: 'Seurre', destinationStation: '', stops: [ 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse' ], }; // état pour l'horaire sélectionné const [selectedHoraire, setSelectedHoraire] = useState(null); const [loadingHoraire, setLoadingHoraire] = useState(false); const [horaireError, setHoraireError] = useState(null); const [candidatesCount, setCandidatesCount] = useState(0); // perturbations/travaux (récupérées depuis Supabase) const [perturbations, setPerturbations] = useState([]); const [, setTravaux] = useState([]); // helpers locaux (simples) — similaires à ceux du composant principal const normalizeName = useCallback((s) => { if (!s) return ''; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); } }, []); 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; }, []); const formatTimeNoSeconds = useCallback((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'); }, []); // ...existing code... 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}`; }, []); // lecture des query params pour gare & quai const paramsForThis = new URLSearchParams(window.location.search || ''); const gareParam = paramsForThis.get('gare'); const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId'); const quaiParam = paramsForThis.get('quai'); const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true'); const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs) const getStops = (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; if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','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 []; }; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) --- const safeParseArrets = (p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return []; if (Array.isArray(raw)) return raw; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed.arrets)) return parsed.arrets; return parsed; } return []; } catch (e) { return []; } }; const parsePerturbArretsObject = (p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return { arrets: [], jours: null }; if (typeof raw === 'object') { if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null }; if (Array.isArray(raw)) return { arrets: raw, jours: null }; return { arrets: [], jours: null }; } try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null }; } return { arrets: [], jours: null }; } catch (e) { return { arrets: [], jours: null }; } }; const getPerturbationJours = useCallback((p) => { if (!p) return null; const candidate = p.jours_perturbation || p.jours || null; const result = new Set(); const pushDate = (d) => { if (!d) return; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){} }; if (candidate) { if (Array.isArray(candidate)) candidate.forEach(item => pushDate(item.date || item)); else if (typeof candidate === 'string') { try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(candidate); } } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); else pushDate(candidate.date || null); } } try { const parsedObj = parsePerturbArretsObject(p); const jp = parsedObj.jours; if (jp) { if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); else if (typeof jp === 'string') { try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } } else if (typeof jp === 'object') pushDate(jp.date || null); } } catch (e) {} return result.size ? result : null; }, [parsePerturbArretsObject]); const parseDelayMinutesFromArret = (arret) => { if (!arret) return null; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min']; for (const k of keys) { if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n); } } return null; }; const buildPerturbMap = useCallback((list = []) => { 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); } }); return idx; }, [normalizeName]); const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) => { const res = []; const map = idx || buildPerturbMap(perturbations || []); 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)); 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) {} } return Array.from(new Set(res)); }; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; 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; for (const p of found) { try { if (horaireYmd) { const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); 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) { if (!joursSet.has(horaireYmd)) continue; } } catch (e) {} const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; const arrets = parsedObj.arrets || []; let matched = false; for (const a of arrets) { 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; const na = normalizeName(aName || ''); const ns = normalizeName(stationName || ''); const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { matched = true; const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } if (!matched) { 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; if (appliesByRef || appliesByNumber || isGlobal) { const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } } if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; return { label: "à l'heure", detail: '', delayMinutes: null }; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours]); // Fetch perturbations & travaux from Supabase periodically useEffect(() => { let mounted = true; let inFlight = false; const fetchPerturb = async () => { if (!mounted) return; if (inFlight) return; inFlight = true; try { if (!supabase) return; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') ]); if (!mounted) return; if (pError || tError) { // ignore quietly — keep previous data } else { // Filter perturbations to those applicable today const todayYmd = new Date().toLocaleDateString('en-CA'); 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 || p.start_date || p.date_debut); const db = toYMD(p.date_debut || p.start_date); const df = toYMD(p.date_fin || p.end_date); if (pDate && pDate !== todayYmd) return false; if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(todayYmd)) return false; } catch (e) {} return true; } catch (e) { return true; } }); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); } } catch (e) { // ignore } finally { inFlight = false; } }; fetchPerturb(); const id = setInterval(fetchPerturb, 15000); return () => { mounted = false; clearInterval(id); }; }, []); // Annotate selectedHoraire with computed status when perturbations change useEffect(() => { if (!selectedHoraire || !selectedHoraire.horaire) return; const horaire = selectedHoraire.horaire; const stop = selectedHoraire.stop || null; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null; try { const map = buildPerturbMap(perturbations || []); const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null); // keep original scheduled time for display (_displayTime) const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure); const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null)); // compute updated time (delayed) but store separately — do not override the original displayed time let updatedTime = null; if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') { updatedTime = formatTimeFromHM(originalBase + status.delayMinutes); } setSelectedHoraire(prev => ({ ...(prev||{}), horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime })); } catch (e) { /* ignore */ } }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]); // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai) useEffect(() => { let mounted = true; async function loadNextForQuai() { setLoadingHoraire(true); setHoraireError(null); setSelectedHoraire(null); if (!supabase) { setHoraireError('Supabase non initialisé'); setLoadingHoraire(false); return; } try { const { data: all, error } = await supabase.from('horaires').select('id, *'); if (error) { if (mounted) setHoraireError(error.message || String(error)); setLoadingHoraire(false); return; } if (!mounted) return; if (!Array.isArray(all) || all.length === 0) { setLoadingHoraire(false); return; } const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null; const now = new Date(); const nowHM = now.getHours()*60 + now.getMinutes(); const candidates = []; for (const h of all) { if (!h) continue; // build stops list (support multiple field names) const stops = getStops(h); // compute max index for terminus detection if stops contain index let maxIdx = null; if (Array.isArray(stops) && stops.length) { for (const st of stops) { 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); } } } // find matching stop (by name or by gare_id) let stopMatch = null; // check horaire-level depart name first if (normalizedTarget) { // exclude horaires where the target gare is the final destination (terminus) const destNameNorm = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || ''); if (destNameNorm && (destNameNorm === normalizedTarget || destNameNorm.includes(normalizedTarget) || normalizedTarget.includes(destNameNorm))) { // gare ciblée est la gare d'arrivée finale -> ne pas considérer cet horaire // skip to next horaire } else { const departName = normalizeName(h.gare_depart_nom || h.gare_depart || h.origin_name || ''); if (departName && (departName === normalizedTarget || departName.includes(normalizedTarget) || normalizedTarget.includes(departName))) { // créer un stopMatch initial à partir du niveau horaire let initialStop = { heure_depart: h.heure_depart || h.time || h.heure, gare_nom: h.gare_depart_nom || h.gare_depart }; // si la liste stops contient un objet correspondant à la gare de départ, préférer cet objet if (Array.isArray(stops) && stops.length) { for (const s of stops) { const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { // prefer the stops entry (it may contain platform/quai information) // but preserve a horaire-level time if the stop entry lacks heure_depart if (typeof s === 'object') { if (!s.heure_depart && initialStop.heure_depart) s.heure_depart = initialStop.heure_depart; initialStop = s; } else { // s is string name — keep initialStop but set gare_nom initialStop.gare_nom = s; } break; } } } stopMatch = initialStop; } } } // if gare_id param present, try to match by id as well if (!stopMatch && gareIdParam) { const gid = String(gareIdParam); // check horaire-level id const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; if (departId != null && String(departId) === gid) { stopMatch = { heure_depart: h.heure_depart || h.time || h.heure, gare_id: departId, gare_nom: h.gare_depart_nom || h.gare_depart }; } // check stops list ids if (!stopMatch && Array.isArray(stops) && stops.length) { for (const s of stops) { const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; if (sid != null && String(sid) === gid) { stopMatch = s; break; } } } } // if still no match, try match by normalized name inside stops if (!stopMatch && normalizedTarget && Array.isArray(stops) && stops.length) { for (const s of stops) { // support both string entries and object entries const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.stopName || s.station || s.station_name || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { // detect if this stop is a terminus (isLast / is_last / index == maxIdx) const isTerminus = Boolean((typeof s === 'object') && (s.isLast || s.is_last || s.isTerminus || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx))); if (isTerminus) continue; // ne pas considérer les terminus stopMatch = s; break; } } } if (!stopMatch) continue; // this horaire does not serve the target gare (or match failed) // determine platform/quai for this stop/horaire const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,''); // if targetQuai is set, ensure it matches (loose compare: contains or equals) if (targetQuai) { if (!normPlatform) continue; // no platform info -> skip if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue; } // Ne garder que les horaires qui ont un départ au stop (on ne veut pas afficher les arrivées) const stopIsObject = stopMatch && typeof stopMatch === 'object'; const hasDeparture = stopIsObject && ( typeof stopMatch.heure_depart !== 'undefined' || typeof stopMatch.departure_time !== 'undefined' || typeof stopMatch.departure !== 'undefined' || typeof stopMatch.time !== 'undefined' || typeof stopMatch.departureTime !== 'undefined' || typeof stopMatch.horaire_time !== 'undefined' ); const hasArrivalOnly = stopIsObject && !hasDeparture && ( typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' ); // si le stop correspond mais n'a que l'heure d'arrivée (pas de départ), ignorer cet horaire — on veut uniquement des départs if (stopIsObject && hasArrivalOnly) continue; // pick time from stopMatch (si on a une heure de départ) ou fallback au niveau horaire const rawTime = (hasDeparture ? (stopMatch.heure_depart || stopMatch.time || stopMatch.heure || stopMatch.departure_time || stopMatch.departure || stopMatch.departureTime || stopMatch.horaire_time) : (h.heure_depart || h.time || h.heure || h.departure_time || h.departure || h.departureTime || h.horaire_time)); const depHM = parseTimeHM(rawTime); if (depHM == null) continue; // compute minutes until departure (allow next-day wrap) let minutesUntil = depHM - nowHM; if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime }); } if (!candidates.length) { setCandidatesCount(0); setLoadingHoraire(false); setSelectedHoraire(null); return; } // split into today (depHM > nowHM) and tomorrow (depHM <= nowHM) const todayCandidates = candidates.filter(c => c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); const tomorrowCandidates = candidates.filter(c => c.depHM <= nowHM).sort((a,b) => a.depHM - b.depHM); let chosen = null; if (todayCandidates.length > 0) chosen = todayCandidates[0]; else if (tomorrowCandidates.length > 0) chosen = tomorrowCandidates[0]; if (mounted) { setCandidatesCount(candidates.length); setSelectedHoraire(chosen || null); } } catch (err) { console.error('Erreur supabase quai', err); if (mounted) setHoraireError(err.message || String(err)); } finally { if (mounted) setLoadingHoraire(false); } } loadNextForQuai(); return () => { mounted = false; }; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM]); // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present) const [showTime, setShowTime] = useState(true); // derive a simple status label string to use in the dependency array (avoids complex expressions) const _selectedStatusLabel = (() => { const horaireObj = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const computedLabel = horaireObj && horaireObj._status && horaireObj._status.label ? String(horaireObj._status.label) : null; const rawStatus = horaireObj && horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status && horaireObj.status.label ? String(horaireObj.status.label) : null)) : null; return String(computedLabel || rawStatus || mock.status || '').toLowerCase(); })(); useEffect(()=>{ let id = null; if (/retard|supprim/.test(_selectedStatusLabel)) { // ensure we start by showing the time setShowTime(true); id = setInterval(() => setShowTime(prev => !prev), 3000); } else { setShowTime(true); } return () => { if (id) clearInterval(id); }; }, [_selectedStatusLabel, mock.status]); // compute and position the rail so it covers from first stop center to last stop center useEffect(() => { function updateRail(){ const rail = railRef.current; const list = stopsListRef.current; const right = rightRef.current; if(!rail || !list || !right) return; const items = Array.from(list.querySelectorAll('li')); if(items.length === 0) return; const first = items[0]; // find destination (last with dest class) or fallback to last item const last = items.find(el => el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1]; const rightRect = right.getBoundingClientRect(); const firstRect = first.getBoundingClientRect(); const lastRect = last.getBoundingClientRect(); const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top; // add small padding so rail extends a bit beyond centers const pad = 9; const top = Math.max(0, firstCenterY - pad); const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2); // compute horizontal position so dots are centered next to the text const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text'); const textRect = firstText ? firstText.getBoundingClientRect() : firstRect; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px const railWidth = 35; const left = centerX - railWidth/2; // apply styles for the rail rectangle rail.style.top = `${Math.round(top)}px`; rail.style.height = `${Math.round(height)}px`; rail.style.left = `${Math.round(left)}px`; rail.style.width = `${railWidth}px`; // remove background-image reliance and render individual dots as DOM children // clear existing dots while (rail.firstChild) rail.removeChild(rail.firstChild); // create a dot element for each item and position it exactly at the item's vertical center const railWidthComputed = parseInt(rail.style.width, 8) || railWidth; items.forEach(it => { const r = it.getBoundingClientRect(); const centerY = r.top + r.height/2 - (rightRect.top + top); const dot = document.createElement('span'); dot.className = 'aff-quais__rail-dot'; dot.style.position = 'absolute'; dot.style.left = '50%'; dot.style.transform = 'translate(-50%, -50%)'; dot.style.top = `${Math.round(centerY)}px`; // set dot diameter to rail width dot.style.width = `${railWidthComputed}px`; dot.style.height = `${railWidthComputed}px`; dot.style.borderRadius = '50%'; dot.style.background = '#ffe600'; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset'; rail.appendChild(dot); }); } updateRail(); window.addEventListener('resize', updateRail); const obs = new MutationObserver(updateRail); const listNode = stopsListRef.current; if(listNode) obs.observe(listNode, { childList:true, subtree:true }); return ()=>{ window.removeEventListener('resize', updateRail); if(obs) obs.disconnect(); }; }, [selectedHoraire]); // Observer changement des stops via selectedHoraire // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent) const displayTimeOrStatus = () => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status; const statusObj = horaire && horaire._status ? horaire._status : null; // prefer precomputed _displayTime if available const time = selectedHoraire && selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time); // build canonical status label when we have computed status object let canonicalStatus = null; if (statusObj) { if (typeof statusObj.delayMinutes === 'number' && !Number.isNaN(statusObj.delayMinutes)) { canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`; } else if (statusObj.label && String(statusObj.label).toLowerCase().includes('supprim')) { canonicalStatus = 'supprimé'; } else if (statusObj.label) { canonicalStatus = String(statusObj.label); } } const s = (canonicalStatus || statusStrFromField || '').toLowerCase(); const isAlert = s.includes('retard') || s.includes('supprim'); const label = canonicalStatus || statusStrFromField || ''; // Always render both time and status inside a fixed-size wrapper to avoid layout shift return ( <span className="aff-quais__time-status-wrapper" aria-live="polite"> <span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}>{time}</span> <span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime && isAlert) ? 'visible' : 'hidden'}`}>{label}</span> </span> ); } return ( <div className="aff-quais__container aff-quais__container--strict aff-quais__container--depart"> <div className="aff-quais__left aff-quais__left--strict"> {/* logo (depuis le type de train) */} {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; return <img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className="aff-quais__type-logo--strict" />; })()} <div className="aff-quais__watermark" aria-hidden>départ</div> {/* affichage des états de chargement / erreur pour debug */} {horaireError ? <div className="aff-quais__error" style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}>{horaireError}</div> : null} {loadingHoraire ? <div className="aff-quais__loading" style={{color:'#666',fontSize:'0.85rem',marginTop:6}}>Chargement...</div> : null} {debugEnabled ? ( <div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}> <div>Candidates: {candidatesCount}</div> {selectedHoraire ? <pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}>{JSON.stringify(selectedHoraire, null, 2)}</pre> : null} </div> ) : null} {/* logo du type en haut à droite */} <div className="aff-quais__row aff-quais__row--time-status"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const status = horaire ? (horaire.status || (horaire._status && horaire._status.label) || mock.status) : mock.status; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim'); if (isAlert) return displayTimeOrStatus(); return ( <> <span className="aff-quais__time aff-quais__time--strict">{time}</span> <span className="aff-quais__time-status aff-quais__time-status--strict">{status}</span> </> ); })()} </div> <div className="aff-quais__destination aff-quais__destination--strict"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const stop = selectedHoraire && selectedHoraire.stop ? selectedHoraire.stop : null; // destinationBig is the final destination of the train (gare_arrivee_*). The second line should be the station name of the stop on the route (if present) — avoid duplicating final destination. const destinationBig = horaire ? (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '') : mock.destinationBig; const rawDestExpr = (stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation)); return ( <> <div className="aff-quais__destination-line1 aff-quais__destination-line1--strict">{destinationBig}</div> {(rawDestExpr && destinationBig && normalizeName(rawDestExpr) !== normalizeName(destinationBig)) ? ( <div className="aff-quais__destination-line2 aff-quais__destination-line2--strict">{rawDestExpr}</div> ) : null} </> ); })()} </div> <div className="aff-quais__train-id aff-quais__train-id--strict">{(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; return (<>{trainType}&nbsp;&nbsp;<span className="aff-quais__train-number--strict">{trainNumber}</span></>); })()}</div> </div> <div className="aff-quais__separator--strict" aria-hidden></div> <div className="aff-quais__right aff-quais__right--strict" ref={rightRef}> {/* barre verticale de points (élément explicite pour fiabilité) */} <div className="aff-quais__stops-rail" aria-hidden ref={railRef} /> <ul className="aff-quais__stops-list--strict" ref={stopsListRef}> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; // utiliser la helper getStops pour supporter plusieurs formats let stops = horaire ? getStops(horaire) : []; if (!Array.isArray(stops) || stops.length === 0) { // fallback parsing si la colonne est une chaîne JSON if (horaire && typeof horaire.stops === 'string') { try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } } } if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; // construire une liste propre en extrayant un label pour chaque stop const labeled = stops.map((stop, idx) => { let label = ''; if (typeof stop === 'string') label = stop; else if (stop && typeof stop === 'object') { // vérifier plusieurs clés possibles label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || ''; // si l'objet possède une clé 'stop' qui est elle-même un objet if (!label && stop.stop && typeof stop.stop === 'object') { label = stop.stop.name || stop.stop.stop_name || ''; } } label = String(label || '').trim(); return { label, original: stop, idx }; }).filter(it => it.label); if (labeled.length === 0) return null; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop) let matchedIndex = -1; try { if (selectedHoraire && selectedHoraire.stop) { const target = selectedHoraire.stop; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || '')); const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null; for (let i = 0; i < stops.length; i++) { const s = stops[i]; // compare by id first if available if (targetId != null) { const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null; if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; } } const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || ''); if (name && targetName && name === targetName) { matchedIndex = i; break; } } } } catch (e) { /* ignore */ } // filtrer pour ne garder que les prochains arrêts après la gare renseignée let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx > matchedIndex) : true); // trier par idx pour être sûr subsequent = subsequent.sort((a,b) => a.idx - b.idx); // Remove consecutive duplicate labels (case-insensitive) if (subsequent.length > 1) { const collapsed = []; for (const it of subsequent) { const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null; const cur = it.label ? String(it.label).trim().toLowerCase() : ''; if (cur && prev && prev === cur) { continue; } collapsed.push(it); } subsequent = collapsed; } // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue if (subsequent.length === 0) return null; return subsequent.map((item, mapIndex) => { const isLast = item.idx === (stops.length - 1); return ( <li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}> <span className="aff-quais__stop-bullet--strict" /> <span className="aff-quais__stop-text--strict">{item.label}</span> </li> ); }); })()} </ul> </div> {/* footer band same as classic departures with clock on the right */} <div className="aff-quais__footer" aria-hidden> <div className="aff-quais__footer-inner"> <div className="aff-quais__footer-left"></div> <div className="aff-quais__footer-right"><Clock /></div> </div> </div> </div> ); } " />
|
|
<option name="updatedContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react'; import './ClassiquesDepartsQuais.css'; import supabase from '../../../../lib/supabase'; // Hook pour récupérer la map des logos depuis /type/data.json function useTypeLogos(){ const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' }); useEffect(() => { let mounted = true; const load = async () => { const candidates = ['/type/quais/data-color.json', '/type/data.json']; for(const url of candidates){ try{ const res = await fetch(url); if(!res.ok) continue; const data = await res.json(); if(!mounted) return; const logos = {}; if(Array.isArray(data.logos)){ data.logos.forEach(item => { // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path let file = item.file; if(!file && item.path){ const parts = String(item.path).split('/'); file = parts[parts.length-1]; } if(!file) return; const basename = file.replace(/\.[^/.]+$/, ''); const semantic = basename.replace(/^logo-/, ''); const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-'); // Prefer color logos in the 'quais' subfolder if data came from the quais file if(url.includes('/quais/')){ logos[key] = `/type/quais/${file}`; } else { logos[key] = `/type/${file}`; } }); } logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png'; setMap(logos); return; }catch(e){ // try next candidate (ignore error and continue loop) } } // no candidate succeeded — keep fallback }; load(); return () => { mounted = false; }; }, []); return map; } function getTypeLogo(type, TYPE_LOGOS_MAP){ const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' }; if(!type) return map.default; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-'); if(map[norm]) return map[norm]; const keys = Object.keys(map).filter(k => k !== 'default'); for(const k of keys){ if(k.includes(norm) || norm.includes(k)) return map[k]; } return map.default; } 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-quais__clock"> <div className="aff-quais__clock-box"><span className="aff-quais__clock-main">{hh}:{mm}:{ss}</span></div> </div> ); } export default function ClassiquesDepartsQuais(){ const typeLogos = useTypeLogos(); const rightRef = useRef(null); const stopsListRef = useRef(null); const railRef = useRef(null); // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée) const mock = { time: '16h44', status: "à l'heure", trainType: 'Fluo', trainNumber: '891811', destinationBig: 'Seurre', destinationStation: '', stops: [ 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse' ], }; // état pour l'horaire sélectionné const [selectedHoraire, setSelectedHoraire] = useState(null); const [loadingHoraire, setLoadingHoraire] = useState(false); const [horaireError, setHoraireError] = useState(null); const [candidatesCount, setCandidatesCount] = useState(0); // perturbations/travaux (récupérées depuis Supabase) const [perturbations, setPerturbations] = useState([]); const [, setTravaux] = useState([]); // helpers locaux (simples) — similaires à ceux du composant principal const normalizeName = useCallback((s) => { if (!s) return ''; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); } }, []); 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; }, []); const formatTimeNoSeconds = useCallback((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'); }, []); // ...existing code... 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}`; }, []); // lecture des query params pour gare & quai const paramsForThis = new URLSearchParams(window.location.search || ''); const gareParam = paramsForThis.get('gare'); const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId'); const quaiParam = paramsForThis.get('quai'); const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true'); const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs) const getStops = (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; if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','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 []; }; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) --- const safeParseArrets = (p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return []; if (Array.isArray(raw)) return raw; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed.arrets)) return parsed.arrets; return parsed; } return []; } catch (e) { return []; } }; const parsePerturbArretsObject = useCallback((p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return { arrets: [], jours: null }; if (typeof raw === 'object') { if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null }; if (Array.isArray(raw)) return { arrets: raw, jours: null }; return { arrets: [], jours: null }; } try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null }; } return { arrets: [], jours: null }; } catch (e) { return { arrets: [], jours: null }; } }, []); const getPerturbationJours = useCallback((p) => { if (!p) return null; const candidate = p.jours_perturbation || p.jours || null; const result = new Set(); const pushDate = (d) => { if (!d) return; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){} }; if (candidate) { if (Array.isArray(candidate)) candidate.forEach(item => pushDate(item.date || item)); else if (typeof candidate === 'string') { try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(candidate); } } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); else pushDate(candidate.date || null); } } try { const parsedObj = parsePerturbArretsObject(p); const jp = parsedObj.jours; if (jp) { if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); else if (typeof jp === 'string') { try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } } else if (typeof jp === 'object') pushDate(jp.date || null); } } catch (e) {} return result.size ? result : null; }, [parsePerturbArretsObject]); const parseDelayMinutesFromArret = (arret) => { if (!arret) return null; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min']; for (const k of keys) { if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n); } } return null; }; const buildPerturbMap = useCallback((list = []) => { 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); } }); return idx; }, [normalizeName]); const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) => { const res = []; const map = idx || buildPerturbMap(perturbations || []); 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)); 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) {} } return Array.from(new Set(res)); }; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; 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; for (const p of found) { try { if (horaireYmd) { const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); 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) { if (!joursSet.has(horaireYmd)) continue; } } catch (e) {} const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; const arrets = parsedObj.arrets || []; let matched = false; for (const a of arrets) { 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; const na = normalizeName(aName || ''); const ns = normalizeName(stationName || ''); const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { matched = true; const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } if (!matched) { 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; if (appliesByRef || appliesByNumber || isGlobal) { const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { const d = parseDelayMinutesFromArret(p) || 0; if (maxDelay == null || d > maxDelay) maxDelay = d; } } } } if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; return { label: "à l'heure", detail: '', delayMinutes: null }; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName]); // Fetch perturbations & travaux from Supabase periodically useEffect(() => { let mounted = true; let inFlight = false; const fetchPerturb = async () => { if (!mounted) return; if (inFlight) return; inFlight = true; try { if (!supabase) return; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') ]); if (!mounted) return; if (pError || tError) { // ignore quietly — keep previous data } else { // Filter perturbations to those applicable today const todayYmd = new Date().toLocaleDateString('en-CA'); 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 || p.start_date || p.date_debut); const db = toYMD(p.date_debut || p.start_date); const df = toYMD(p.date_fin || p.end_date); if (pDate && pDate !== todayYmd) return false; if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(todayYmd)) return false; } catch (e) {} return true; } catch (e) { return true; } }); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); } } catch (e) { // ignore } finally { inFlight = false; } }; fetchPerturb(); const id = setInterval(fetchPerturb, 15000); return () => { mounted = false; clearInterval(id); }; }, [getPerturbationJours]); // Annotate selectedHoraire with computed status when perturbations change useEffect(() => { if (!selectedHoraire || !selectedHoraire.horaire) return; const horaire = selectedHoraire.horaire; const stop = selectedHoraire.stop || null; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null; try { const map = buildPerturbMap(perturbations || []); const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null); // keep original scheduled time for display (_displayTime) const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure); const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null)); // compute updated time (delayed) but store separately — do not override the original displayed time let updatedTime = null; if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') { updatedTime = formatTimeFromHM(originalBase + status.delayMinutes); } setSelectedHoraire(prev => ({ ...(prev||{}), horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime })); } catch (e) { /* ignore */ } }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]); // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai) useEffect(() => { let mounted = true; async function loadNextForQuai() { setLoadingHoraire(true); setHoraireError(null); setSelectedHoraire(null); if (!supabase) { setHoraireError('Supabase non initialisé'); setLoadingHoraire(false); return; } try { const { data: all, error } = await supabase.from('horaires').select('id, *'); if (error) { if (mounted) setHoraireError(error.message || String(error)); setLoadingHoraire(false); return; } if (!mounted) return; if (!Array.isArray(all) || all.length === 0) { setLoadingHoraire(false); return; } const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null; const now = new Date(); const nowHM = now.getHours()*60 + now.getMinutes(); const candidates = []; for (const h of all) { if (!h) continue; // build stops list (support multiple field names) const stops = getStops(h); // compute max index for terminus detection if stops contain index let maxIdx = null; if (Array.isArray(stops) && stops.length) { for (const st of stops) { 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); } } } // find matching stop (by name or by gare_id) let stopMatch = null; // check horaire-level depart name first if (normalizedTarget) { // exclude horaires where the target gare is the final destination (terminus) const destNameNorm = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || ''); if (destNameNorm && (destNameNorm === normalizedTarget || destNameNorm.includes(normalizedTarget) || normalizedTarget.includes(destNameNorm))) { // gare ciblée est la gare d'arrivée finale -> ne pas considérer cet horaire // skip to next horaire } else { const departName = normalizeName(h.gare_depart_nom || h.gare_depart || h.origin_name || ''); if (departName && (departName === normalizedTarget || departName.includes(normalizedTarget) || normalizedTarget.includes(departName))) { // créer un stopMatch initial à partir du niveau horaire let initialStop = { heure_depart: h.heure_depart || h.time || h.heure, gare_nom: h.gare_depart_nom || h.gare_depart }; // si la liste stops contient un objet correspondant à la gare de départ, préférer cet objet if (Array.isArray(stops) && stops.length) { for (const s of stops) { const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { // prefer the stops entry (it may contain platform/quai information) // but preserve a horaire-level time if the stop entry lacks heure_depart if (typeof s === 'object') { if (!s.heure_depart && initialStop.heure_depart) s.heure_depart = initialStop.heure_depart; initialStop = s; } else { // s is string name — keep initialStop but set gare_nom initialStop.gare_nom = s; } break; } } } stopMatch = initialStop; } } } // if gare_id param present, try to match by id as well if (!stopMatch && gareIdParam) { const gid = String(gareIdParam); // check horaire-level id const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; if (departId != null && String(departId) === gid) { stopMatch = { heure_depart: h.heure_depart || h.time || h.heure, gare_id: departId, gare_nom: h.gare_depart_nom || h.gare_depart }; } // check stops list ids if (!stopMatch && Array.isArray(stops) && stops.length) { for (const s of stops) { const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; if (sid != null && String(sid) === gid) { stopMatch = s; break; } } } } // if still no match, try match by normalized name inside stops if (!stopMatch && normalizedTarget && Array.isArray(stops) && stops.length) { for (const s of stops) { // support both string entries and object entries const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.stopName || s.station || s.station_name || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { // detect if this stop is a terminus (isLast / is_last / index == maxIdx) const isTerminus = Boolean((typeof s === 'object') && (s.isLast || s.is_last || s.isTerminus || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx))); if (isTerminus) continue; // ne pas considérer les terminus stopMatch = s; break; } } } if (!stopMatch) continue; // this horaire does not serve the target gare (or match failed) // determine platform/quai for this stop/horaire const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,''); // if targetQuai is set, ensure it matches (loose compare: contains or equals) if (targetQuai) { if (!normPlatform) continue; // no platform info -> skip if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue; } // Ne garder que les horaires qui ont un départ au stop (on ne veut pas afficher les arrivées) const stopIsObject = stopMatch && typeof stopMatch === 'object'; const hasDeparture = stopIsObject && ( typeof stopMatch.heure_depart !== 'undefined' || typeof stopMatch.departure_time !== 'undefined' || typeof stopMatch.departure !== 'undefined' || typeof stopMatch.time !== 'undefined' || typeof stopMatch.departureTime !== 'undefined' || typeof stopMatch.horaire_time !== 'undefined' ); const hasArrivalOnly = stopIsObject && !hasDeparture && ( typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' ); // si le stop correspond mais n'a que l'heure d'arrivée (pas de départ), ignorer cet horaire — on veut uniquement des départs if (stopIsObject && hasArrivalOnly) continue; // pick time from stopMatch (si on a une heure de départ) ou fallback au niveau horaire const rawTime = (hasDeparture ? (stopMatch.heure_depart || stopMatch.time || stopMatch.heure || stopMatch.departure_time || stopMatch.departure || stopMatch.departureTime || stopMatch.horaire_time) : (h.heure_depart || h.time || h.heure || h.departure_time || h.departure || h.departureTime || h.horaire_time)); const depHM = parseTimeHM(rawTime); if (depHM == null) continue; // compute minutes until departure (allow next-day wrap) let minutesUntil = depHM - nowHM; if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime }); } if (!candidates.length) { setCandidatesCount(0); setLoadingHoraire(false); setSelectedHoraire(null); return; } // split into today (depHM > nowHM) and tomorrow (depHM <= nowHM) const todayCandidates = candidates.filter(c => c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); const tomorrowCandidates = candidates.filter(c => c.depHM <= nowHM).sort((a,b) => a.depHM - b.depHM); let chosen = null; if (todayCandidates.length > 0) chosen = todayCandidates[0]; else if (tomorrowCandidates.length > 0) chosen = tomorrowCandidates[0]; if (mounted) { setCandidatesCount(candidates.length); setSelectedHoraire(chosen || null); } } catch (err) { console.error('Erreur supabase quai', err); if (mounted) setHoraireError(err.message || String(err)); } finally { if (mounted) setLoadingHoraire(false); } } loadNextForQuai(); return () => { mounted = false; }; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName]); // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present) const [showTime, setShowTime] = useState(true); // derive a simple status label string to use in the dependency array (avoids complex expressions) const _selectedStatusLabel = (() => { const horaireObj = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const computedLabel = horaireObj && horaireObj._status && horaireObj._status.label ? String(horaireObj._status.label) : null; const rawStatus = horaireObj && horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status && horaireObj.status.label ? String(horaireObj.status.label) : null)) : null; return String(computedLabel || rawStatus || mock.status || '').toLowerCase(); })(); useEffect(()=>{ let id = null; if (/retard|supprim/.test(_selectedStatusLabel)) { // ensure we start by showing the time setShowTime(true); id = setInterval(() => setShowTime(prev => !prev), 3000); } else { setShowTime(true); } return () => { if (id) clearInterval(id); }; }, [_selectedStatusLabel, mock.status]); // compute and position the rail so it covers from first stop center to last stop center useEffect(() => { function updateRail(){ const rail = railRef.current; const list = stopsListRef.current; const right = rightRef.current; if(!rail || !list || !right) return; const items = Array.from(list.querySelectorAll('li')); if(items.length === 0) return; const first = items[0]; // find destination (last with dest class) or fallback to last item const last = items.find(el => el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1]; const rightRect = right.getBoundingClientRect(); const firstRect = first.getBoundingClientRect(); const lastRect = last.getBoundingClientRect(); const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top; // add small padding so rail extends a bit beyond centers const pad = 9; const top = Math.max(0, firstCenterY - pad); const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2); // compute horizontal position so dots are centered next to the text const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text'); const textRect = firstText ? firstText.getBoundingClientRect() : firstRect; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px const railWidth = 35; const left = centerX - railWidth/2; // apply styles for the rail rectangle rail.style.top = `${Math.round(top)}px`; rail.style.height = `${Math.round(height)}px`; rail.style.left = `${Math.round(left)}px`; rail.style.width = `${railWidth}px`; // remove background-image reliance and render individual dots as DOM children // clear existing dots while (rail.firstChild) rail.removeChild(rail.firstChild); // create a dot element for each item and position it exactly at the item's vertical center const railWidthComputed = parseInt(rail.style.width, 8) || railWidth; items.forEach(it => { const r = it.getBoundingClientRect(); const centerY = r.top + r.height/2 - (rightRect.top + top); const dot = document.createElement('span'); dot.className = 'aff-quais__rail-dot'; dot.style.position = 'absolute'; dot.style.left = '50%'; dot.style.transform = 'translate(-50%, -50%)'; dot.style.top = `${Math.round(centerY)}px`; // set dot diameter to rail width dot.style.width = `${railWidthComputed}px`; dot.style.height = `${railWidthComputed}px`; dot.style.borderRadius = '50%'; dot.style.background = '#ffe600'; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset'; rail.appendChild(dot); }); } updateRail(); window.addEventListener('resize', updateRail); const obs = new MutationObserver(updateRail); const listNode = stopsListRef.current; if(listNode) obs.observe(listNode, { childList:true, subtree:true }); return ()=>{ window.removeEventListener('resize', updateRail); if(obs) obs.disconnect(); }; }, [selectedHoraire]); // Observer changement des stops via selectedHoraire // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent) const displayTimeOrStatus = () => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status; const statusObj = horaire && horaire._status ? horaire._status : null; // prefer precomputed _displayTime if available const time = selectedHoraire && selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time); // build canonical status label when we have computed status object let canonicalStatus = null; if (statusObj) { if (typeof statusObj.delayMinutes === 'number' && !Number.isNaN(statusObj.delayMinutes)) { canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`; } else if (statusObj.label && String(statusObj.label).toLowerCase().includes('supprim')) { canonicalStatus = 'supprimé'; } else if (statusObj.label) { canonicalStatus = String(statusObj.label); } } const s = (canonicalStatus || statusStrFromField || '').toLowerCase(); const isAlert = s.includes('retard') || s.includes('supprim'); const label = canonicalStatus || statusStrFromField || ''; // Always render both time and status inside a fixed-size wrapper to avoid layout shift return ( <span className="aff-quais__time-status-wrapper" aria-live="polite"> <span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}>{time}</span> <span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime && isAlert) ? 'visible' : 'hidden'}`}>{label}</span> </span> ); } return ( <div className="aff-quais__container aff-quais__container--strict aff-quais__container--depart"> <div className="aff-quais__left aff-quais__left--strict"> {/* logo (depuis le type de train) */} {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; return <img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className="aff-quais__type-logo--strict" />; })()} <div className="aff-quais__watermark" aria-hidden>départ</div> {/* affichage des états de chargement / erreur pour debug */} {horaireError ? <div className="aff-quais__error" style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}>{horaireError}</div> : null} {loadingHoraire ? <div className="aff-quais__loading" style={{color:'#666',fontSize:'0.85rem',marginTop:6}}>Chargement...</div> : null} {debugEnabled ? ( <div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}> <div>Candidates: {candidatesCount}</div> {selectedHoraire ? <pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}>{JSON.stringify(selectedHoraire, null, 2)}</pre> : null} </div> ) : null} {/* logo du type en haut à droite */} <div className="aff-quais__row aff-quais__row--time-status"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const status = horaire ? (horaire.status || (horaire._status && horaire._status.label) || mock.status) : mock.status; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim'); if (isAlert) return displayTimeOrStatus(); return ( <> <span className="aff-quais__time aff-quais__time--strict">{time}</span> <span className="aff-quais__time-status aff-quais__time-status--strict">{status}</span> </> ); })()} </div> <div className="aff-quais__destination aff-quais__destination--strict"> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const stop = selectedHoraire && selectedHoraire.stop ? selectedHoraire.stop : null; // destinationBig is the final destination of the train (gare_arrivee_*). The second line should be the station name of the stop on the route (if present) — avoid duplicating final destination. const destinationBig = horaire ? (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '') : mock.destinationBig; const rawDestExpr = (stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation)); return ( <> <div className="aff-quais__destination-line1 aff-quais__destination-line1--strict">{destinationBig}</div> {(rawDestExpr && destinationBig && normalizeName(rawDestExpr) !== normalizeName(destinationBig)) ? ( <div className="aff-quais__destination-line2 aff-quais__destination-line2--strict">{rawDestExpr}</div> ) : null} </> ); })()} </div> <div className="aff-quais__train-id aff-quais__train-id--strict">{(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; return (<>{trainType}&nbsp;&nbsp;<span className="aff-quais__train-number--strict">{trainNumber}</span></>); })()}</div> </div> <div className="aff-quais__separator--strict" aria-hidden></div> <div className="aff-quais__right aff-quais__right--strict" ref={rightRef}> {/* barre verticale de points (élément explicite pour fiabilité) */} <div className="aff-quais__stops-rail" aria-hidden ref={railRef} /> <ul className="aff-quais__stops-list--strict" ref={stopsListRef}> {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; // utiliser la helper getStops pour supporter plusieurs formats let stops = horaire ? getStops(horaire) : []; if (!Array.isArray(stops) || stops.length === 0) { // fallback parsing si la colonne est une chaîne JSON if (horaire && typeof horaire.stops === 'string') { try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } } } if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; // construire une liste propre en extrayant un label pour chaque stop const labeled = stops.map((stop, idx) => { let label = ''; if (typeof stop === 'string') label = stop; else if (stop && typeof stop === 'object') { // vérifier plusieurs clés possibles label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || ''; // si l'objet possède une clé 'stop' qui est elle-même un objet if (!label && stop.stop && typeof stop.stop === 'object') { label = stop.stop.name || stop.stop.stop_name || ''; } } label = String(label || '').trim(); return { label, original: stop, idx }; }).filter(it => it.label); if (labeled.length === 0) return null; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop) let matchedIndex = -1; try { if (selectedHoraire && selectedHoraire.stop) { const target = selectedHoraire.stop; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || '')); const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null; for (let i = 0; i < stops.length; i++) { const s = stops[i]; // compare by id first if available if (targetId != null) { const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null; if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; } } const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || ''); if (name && targetName && name === targetName) { matchedIndex = i; break; } } } } catch (e) { /* ignore */ } // filtrer pour ne garder que les prochains arrêts après la gare renseignée let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx > matchedIndex) : true); // trier par idx pour être sûr subsequent = subsequent.sort((a,b) => a.idx - b.idx); // Remove consecutive duplicate labels (case-insensitive) if (subsequent.length > 1) { const collapsed = []; for (const it of subsequent) { const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null; const cur = it.label ? String(it.label).trim().toLowerCase() : ''; if (cur && prev && prev === cur) { continue; } collapsed.push(it); } subsequent = collapsed; } // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue if (subsequent.length === 0) return null; return subsequent.map((item, mapIndex) => { const isLast = item.idx === (stops.length - 1); return ( <li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}> <span className="aff-quais__stop-bullet--strict" /> <span className="aff-quais__stop-text--strict">{item.label}</span> </li> ); }); })()} </ul> </div> {/* footer band same as classic departures with clock on the right */} <div className="aff-quais__footer" aria-hidden> <div className="aff-quais__footer-inner"> <div className="aff-quais__footer-left"></div> <div className="aff-quais__footer-right"><Clock /></div> </div> </div> </div> ); } " />
|
|
</PendingDiffInfo>
|
|
</value>
|
|
</entry>
|
|
</map>
|
|
</option>
|
|
</component>
|
|
</project> |