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 (
{hh}:{mm}:{ss}
); } 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); // type de la gare (récupéré depuis la table gares) const [stationType, setStationType] = useState(null); // Heure actuelle mise à jour chaque seconde pour la logique de seuil const [currentTime, setCurrentTime] = useState(new Date()); useEffect(() => { const id = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(id); }, []); // perturbations/travaux (récupérées depuis Supabase) const [perturbations, setPerturbations] = useState([]); const [, setTravaux] = useState([]); // helpers locaux (simples) — similaires à ceux du composant principal const weekdayNameForColumn = useCallback((d) => { const i = d.getDay(); switch(i) { case 1: return 'circule_lundi'; case 2: return 'circule_mardi'; case 3: return 'circule_mercredi'; case 4: return 'circule_jeudi'; case 5: return 'circule_vendredi'; case 6: return 'circule_samedi'; case 0: return 'circule_dimanche'; default: return null; } }, []); const isPublicHoliday = useCallback((d) => { const y = d.getFullYear(); const m = d.getMonth() + 1; const day = d.getDate(); const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const holidays = [ `${y}-01-01`, // Jour de l'an `${y}-05-01`, // Fête du Travail `${y}-05-08`, // Victoire 1945 `${y}-07-14`, // Fête nationale `${y}-08-15`, // Assomption `${y}-11-01`, // Toussaint `${y}-11-11`, // Armistice 1918 `${y}-12-25`, // Noël ]; // Dates variables pour 2026 if (y === 2026) { holidays.push('2026-04-06'); // Lundi de Pâques holidays.push('2026-05-14'); // Ascension holidays.push('2026-05-25'); // Lundi de Pentecôte } return holidays.includes(s); }, []); const circulatesOn = useCallback((h, d) => { if (isPublicHoliday(d)) { const v = h['circule_jours_feries']; 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'; } return (typeof v === 'undefined' || v === null) ? false : Boolean(v); } 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'; } return (typeof v === 'undefined' || v === null) ? true : Boolean(v); }, [isPublicHoliday, weekdayNameForColumn]); 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}`; }, []); // 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).trim().toLowerCase(); if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes return 0; }, []); // 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 = useCallback((arret) => { if (!arret) return null; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min']; for (const k of keys) { if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n); } } return null; }, []); const buildPerturbMap = useCallback((list = []) => { 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 = useCallback((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)); }, [buildPerturbMap, perturbations, normalizeName]); 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, parsePerturbArretsObject]); // 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 { // Récupérer le type de la gare let fetchedType = null; try { if (gareIdParam) { const { data: g } = await supabase.from('gares').select('type').eq('gare_id', gareIdParam).single(); if (g) fetchedType = g.type; } else if (gareDisplayParam) { const { data: g } = await supabase.from('gares').select('type').ilike('nom', `%${gareDisplayParam}%`).limit(1).single(); if (g) fetchedType = g.type; } } catch (e) { /* ignore */ } if (mounted) setStationType(fetchedType); 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 tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); const nowHM = now.getHours()*60 + now.getMinutes(); const candidates = []; for (const h of all) { if (!h) continue; // Filtrage par jour de circulation const circulatesToday = circulatesOn(h, now); const circulatesTomorrow = circulatesOn(h, tomorrow); if (!circulatesToday && !circulatesTomorrow) 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, circulatesToday, circulatesTomorrow }); } if (!candidates.length) { setCandidatesCount(0); setLoadingHoraire(false); setSelectedHoraire(null); return; } // split into today (circulatesToday AND depHM > nowHM) and tomorrow (circulatesTomorrow AND (depHM <= nowHM OR not circulatesToday)) const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM <= nowHM || !c.circulatesToday)).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, circulatesOn]); // 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 || ''; // Logique de seuil d'affichage du quai const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); const sched = selectedHoraire && selectedHoraire.depHM ? selectedHoraire.depHM : null; const threshold = thresholdForStationType(stationType); const showPlatform = (sched !== null) ? (nowHM >= (sched - threshold)) : true; if (!showPlatform) { return ( --h-- ); } // Always render both time and status inside a fixed-size wrapper to avoid layout shift return ( {time} {label} ); } return (
{/* 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 {trainType; })()}
départ
{/* affichage des états de chargement / erreur pour debug */} {horaireError ?
{horaireError}
: null} {loadingHoraire ?
Chargement...
: null} {debugEnabled ? (
Candidates: {candidatesCount}
{selectedHoraire ?
{JSON.stringify(selectedHoraire, null, 2)}
: null}
) : null} {/* logo du type en haut à droite */}
{(() => { 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 ( <> {time} {status} ); })()}
{(() => { 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 ( <>
{destinationBig}
); })()}
{(() => { 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}  {trainNumber}); })()}
{/* barre verticale de points (élément explicite pour fiabilité) */}
    {(() => { 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 (
  • {item.label}
  • ); }); })()}
{/* footer band same as classic departures with clock on the right */}
); }