Initial commit

Created from https://vercel.com/new
This commit is contained in:
MrPatator230 committed 2026-03-19 15:46:03 +00:00
commit 3fcdf7e5ef
172 files changed
+52525

No files matched your search

@@ -0,0 +1,398 @@
/* Copié depuis ClassiquesDeparts.css — styles identiques pour les arrivées */
:where(.classiques-arrivees-container, .aff-quais__container--arrivees) {
/* variables locales aux composants Arrivées (ne pas définir sur :root) */
--green-dark: #2d522c; /* bande foncée */
--green: #38723c; /* bande claire */
--accent-yellow: #e1c14c; /* heure */
--white: #f3f3f3;
--rows: 9; /* 9 horaires visibles + 1 footer */
--footer-height: 100px; /* hauteur de la dernière 'ligne' servant de footer */
--watermark-green: rgb(75, 154, 84);
}
/* Déclaration des fontes Achemine (dans public/fonts/) */
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
.classiques-arrivees-container{
position: relative;
width: 100%;
height: 100vh;
/* autoriser l'affichage des boîtes agrandies qui débordent des lignes */
overflow: visible;
font-family: "Achemine", "Helvetica Neue", Arial, sans-serif;
background: var(--green-dark);
/* plus de padding-bottom : le footer sera superposé en position absolute */
}
.station-box-arrivees{
position: absolute;
left: 18px;
top: 12px;
color: #ffffff;
background: rgba(0,0,0,0.08);
padding: 6px 10px;
border-radius: 6px;
font-weight: 700;
z-index: 40;
}
.classiques-arrivees-container{
border-bottom-right-radius: 10px;
border-bottom-left-radius: 4px;
}
.arrivees-list{
list-style: none;
margin: 0;
padding: 0;
/* La liste occupe toute la hauteur de l'écran; le footer sera affiché
en position absolue par-dessus la partie inférieure. */
height: 100vh;
display: flex;
flex-direction: column;
}
.arrivee-row{
display: flex;
/* laisser les colonnes occuper toute la hauteur de la ligne, puis centrer leur contenu */
align-items: stretch;
box-sizing: border-box;
padding: 0 1.2vw;
/* Répartit l'espace en var(--rows) parts ; on utilise 100% / var(--rows) pour que
9 lignes + 1 footer occupent l'écran de façon uniforme. */
flex: 0 0 calc(100% / var(--rows));
min-height: 50px; /* garde une bonne hauteur sur petits écrans */
max-height: 120px;
position: relative; /* pour positionner la .platform-box en absolu */
overflow: visible; /* laisser la platform-box déborder si agrandie */
}
.arrivee-row:nth-child(odd){
background: var(--green);
}
.arrivee-row:nth-child(even){
background: var(--green-dark);
}
/* Cacher la dernière ligne qui sera sous le footer */
/* ne plus cacher la dernière ligne — on laisse le footer la recouvrir visuellement */
.col-logo-arrivees{
max-width: 100px;
display:flex;
/* organiser verticalement les éléments et les centrer verticalement */
flex-direction: column;
justify-content: center; /* centre verticalement le contenu (logo + meta) */
align-items: flex-start; /* garder l'alignement horizontal à gauche */
flex: 0 0 10%; /* largeur fixe pour se comporter comme une colonne de tableau */
}
.logo-box-arrivees{
/* pastille/logo : ne pas forcer la hauteur pour permettre l'empilement vertical */
height: auto;
width: 100px;
display:flex;
align-items:flex-start;
justify-content:center;
font-weight:800;
font-size: clamp(14px, 1.6vw, 20px);
box-sizing: border-box;
flex: 0 0 auto;
align-self: flex-start; /* centrer la pastille dans la colonne */
}
/* Logo SVG chargé depuis public/type */
.op-logo-arrivees{
max-width: 90%;
max-height: 100%;
height: auto;
}
/* Texte à l'intérieur de la pastille/logo (quai) */
.logo-text-arrivees{
text-align: center;
/* taille plus grande, responsive ; s'assure qu'elle reste lisible sur petits et grands écrans */
font-size: 20px;
line-height: 1;
letter-spacing: 0.5px;
}
.col-time-arrivees{
max-width: 15%;
padding-left: 20px;
display:flex;
/* colonne verticale : centrer verticalement l'heure */
flex-direction: column;
align-items: center;
justify-content: center;
flex: 0 0 18%; /* largeur fixe pour alignement table-like */
}
.time-arrivees{
color: var(--accent-yellow);
font-weight:850;
/* tailles responsives similaires à l'image */
font-size: clamp(26px, 7.5vw, 70px);
line-height: 1;
display: flex;
align-items: baseline; /* centre le chiffre verticalement */
height: auto;
width: 100%; /* occupe toute la colonne pour être centré */
text-align: center;
}
.col-destination-arrivees{
flex: 1 1 auto; /* colonne flexible qui prend l'espace restant */
display: flex;
/* colonne verticale : centrer verticalement la destination */
flex-direction: column;
justify-content: center;
align-items: flex-start; /* garder le texte aligné à gauche */
}
.destination-arrivees{
color: var(--white);
font-weight: 500;
font-size: clamp(28px, 8vw, 80px);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.col-platform-arrivees{
/* La colonne laisse la boîte gérer sa taille ; on aligne la boîte à droite
et l'on centre verticalement pour qu'elle suive la hauteur de la ligne. */
display:flex;
flex: 0 0 8%;
align-items: center;
justify-content: flex-end;
padding-right: 0.5vw;
box-sizing: border-box;
}
.platform-box-arrivees{
/* Boîte quai : garder un format carré mais ne pas forcer l'étirement vertical */
height: 90px; /* limite maximale, garde l'aspect visuel */
width: 90px;
aspect-ratio: 1 / 1; /* carré strict */
display:flex;
align-items:center;
justify-content:center;
background: transparent; /* intérieur transparent pour montrer la couleur de la ligne */
border-radius: 12px;
box-sizing: border-box;
border: 4px solid #ffffff; /* bord blanc épais */
padding: 8px; /* espace intérieur pour éloigner le texte du bord */
flex: 0 0 auto;
align-self: center;
}
.platform-text-arrivees{
color: #ffffff;
font-weight: 900;
/* réduire légèrement la taille du texte afin qu'il ne touche pas la bordure */
font-size: clamp(60px, 2.6vw, 100px);
line-height:1;
letter-spacing: 0.8px;
}
/* removed duplicated/corrupted block */
.col-train-type-arrivees{
max-width: 9%;
display:flex;
/* colonne verticale : centrer verticalement l'heure */
flex-direction: column;
align-items: stretch;
justify-content: center;
flex: 0 0 18%; /* largeur fixe pour alignement table-like */
}
.train-type-box-arrivees{
height: auto;
aspect-ratio: 1 / 1;
display:flex;
align-items:center;
justify-content:center;
background: transparent;
border-radius: 10px;
box-sizing: border-box;
}
.train-type-fallback-arrivees{ text-align:center; }
.train-meta-arrivees{
flex-direction:column;
align-items:flex-start;
/* centrer verticalement sans forcer la hauteur (pour permettre l'empilement) */
justify-content: center;
color: #ffffff;
height: auto;
flex: 0 0 auto;
}
.train-meta-arrivees .op-name-arrivees{
font-weight:900;
font-size:1.50rem; /* plus visible */
padding-left: 20px;
line-height:1;
justify-content: flex-start;
text-align: left; /* aligner à gauche dans la colonne */
}
.train-meta-arrivees .op-number-arrivees{
font-weight:500;
font-size:1.25rem;
padding-left: 20px;
text-align: left; /* aligner à gauche */
margin: 0 0 2px 0;
}
.train-meta-arrivees.status-delayed-arrivees .op-name-arrivees,
.train-meta-arrivees.status-delayed-arrivees .op-number-arrivees,
.train-meta-arrivees.status-suppressed-arrivees .op-name-arrivees,
.train-meta-arrivees.status-suppressed-arrivees .op-number-arrivees{
color: var(--accent-yellow);
}
/* Si on veut un alignement plus spécifique quand train-meta apparaît dans la colonne de type */
.col-train-type-arrivees .train-meta-arrivees{ align-items: flex-start; justify-content: center; }
.col-train-type-arrivees .train-meta-arrivees .op-name-arrivees,
.col-train-type-arrivees .train-meta-arrivees .op-number-arrivees{ text-align: left; }
.watermark-arrivees{
position: absolute;
/* placer strictement à droite et faire déborder le watermark en dehors de l'afficheur */
right: -6vw; /* décalage vers l'extérieur pour qu'il soit strictement à droite */
top: 50%;
transform: translateY(-50%) rotate(-90deg);
transform-origin: center;
font-size: 10.8vw;
color: var(--watermark-green);
font-weight:800;
letter-spacing: 6px;
pointer-events: none;
user-select: none;
/* s'assurer qu'il soit au-dessus des lignes */
z-index: 45;
}
.helpline-arrivees{
position: absolute;
right: 18px;
top: 12px;
width:80px;
height:80px;
border-radius:6px;
border:3px solid #fff;
color:#fff;
display:flex;
align-items:center;
justify-content:center;
font-weight:900;
}
.clock-container-arrivees{
position: relative; /* contenu positionné par le footer */
pointer-events: none;
z-index: 30; /* au-dessus du footer */
display: inline-flex;
align-items: center;
height: 100%; /* prendra la hauteur de la ligne footer */
}
.clock-box-arrivees{
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--green-dark); /* fond foncé interne comme sur la maquette */
border-radius: 10px;
padding: 4px 8px; /* réduire padding pour diminuer légèrement la taille globale */
border: 6px solid #ffffff; /* bordure un peu moins épaisse */
box-shadow: 0 2px 0 rgba(0,0,0,0.12);
box-sizing: border-box;
overflow: hidden;
/* hauteur adaptée pour rentrer exactement dans la ligne footer */
height: calc(var(--footer-height) - 12px);
min-height: 34px;
}
.afficheur-footer-arrivees{
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--footer-height);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 20; /* en dessous de l'horloge */
}
.afficheur-footer-arrivees .footer-inner-arrivees{
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center; /* centre verticalement le contenu (la clock) */
justify-content: flex-end; /* place la clock à droite */
padding-right: 12px; /* espace entre le bord et la clock pour le coin arrondi */
/* léger dégradé pour donner l'effet de bande plus claire en haut */
background: linear-gradient(to bottom, rgba(11,123,180,0.18) 0%, var(--green-dark) 30%);
box-shadow: inset 0 6px 0 rgba(0,0,0,0.08);
/* arrondir côté droit comme sur la maquette */
}
/* légère ligne (accent) en haut du footer pour séparer visuellement */
.afficheur-footer-arrivees .footer-inner-arrivees::before{
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 8px;
background: rgba(255,255,255,0.03);
pointer-events: none;
border-top-right-radius: 12px;
}
.clock-main-arrivees{
color: #ffffff;
font-weight: 800;
/* légèrement plus petit pour tenir dans le footer */
font-size: clamp(28px, 4.2vw, 60px);
line-height: 1;
font-family: "Achemine", "Helvetica Neue", Arial, sans-serif;
margin-right: 6px;
}
.clock-seconds-arrivees{
color: var(--accent-yellow);
font-weight: 800;
font-size: clamp(16px, 2.8vw, 36px);
line-height: 1;
}
/* Ajustements pour petits écrans */
@media (max-width: 700px){
.col-logo-arrivees{width: 18%;}
.col-time-arrivees{width: 24%;}
.watermark-arrivees{display:none}
}
@@ -0,0 +1,746 @@
import React, { useEffect, useState, useCallback } from 'react';
import './ClassiquesArrivees.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');
};
function Logo({op}){
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;
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return `logo-${slug}.svg`;
};
const fileName = mapping[op] || mapping[op && op.split && op.split(' ')[0]] || defaultFileFromOp(op);
const [hasImg, setHasImg] = React.useState(true);
return (
<div className="logo-box-arrivees" aria-hidden>
{hasImg && fileName ? (
<img
src={`/type/${fileName}`}
alt={op}
className="op-logo-arrivees"
onError={()=> setHasImg(false)}
/>
) : (
<div className="logo-text-arrivees" aria-hidden>{(op||'').split(' ')[0]}</div>
)}
</div>
);
}
function Clock(){
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return (
<div className="clock-container-arrivees" aria-hidden>
<div className="clock-box-arrivees">
<div className="clock-main-arrivees">{hh}:{mm}</div>
<div className="clock-seconds-arrivees">{ss}</div>
</div>
</div>
);
}
export default function ClassiquesArrivees(){
// lire le paramètre de requête ?gare=Nom-de-la-gare
const params = new URLSearchParams(window.location.search);
const gareParam = params.get('gare');
// support pour ?gare_id=123 ou ?gareId=123
const gareIdParam = params.get('gare_id') || params.get('gareId');
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;
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);
useEffect(() => {
const id = setInterval(() => setShowStatus(s => !s), 3000);
return () => clearInterval(id);
}, []);
// Données
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);
const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] });
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); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed.arrets)) return parsed.arrets; return parsed; } return []; } catch (e) { return []; }
}, []);
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;
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 [];
}, []);
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 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}`;
}, []);
// parse HH:mm or ISO into minutes since midnight (utilisable dans le rendu)
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 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 (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] || [];
(idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p));
}
}
}
}
} catch (e) {}
}
return idx;
}, [perturbations, allHoraires, safeParseArrets, normalizeName]);
const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => {
const res = [];
const map = idx || buildPerturbMap();
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, 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, parsePerturbArretsObject, getPerturbationJours, parseDelayMinutesFromArret, normalizeName]);
const applyPerturbationsToHoraires = useCallback((list = [], perturbList = perturbations, displayStationId = null, displayStationName = null, serviceDate = null) => {
const map = buildPerturbMap(perturbList || []);
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 (arrivée)
const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate);
let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || '');
if (displayStatus && typeof displayStatus.delayMinutes === 'number' && !isNaN(displayStatus.delayMinutes)) {
if (typeof horaire._arrHM === 'number') {
displayTime = formatTimeFromHM(horaire._arrHM + 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._rawArrTime || horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || '');
if (typeof base === 'number') displayTime = formatTimeFromHM(base + displayStatus.delayMinutes);
}
}
horaire._stopsStatus = stopsStatus;
// prefer platform from stop data (arrivée) if available — check several possible keys
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;
// arrival platform keys preferred, then fallback to generic platform
horaire._displayPlatform = (stopForStation && (stopForStation.quai_arrivee || stopForStation.platform_arrivee || stopForStation.quay_arrivee || stopForStation.arrival_platform || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || horaire.arrival_platform || horaire.platform || '';
return horaire;
});
}, [buildPerturbMap, computeStatusForHoraireAtStop, formatTimeFromHM, perturbations, gareDisplay, gareIdParam, getStops, normalizeName]);
useEffect(() => {
let mounted = true;
let inFlight = false;
const fetchData = async () => {
if (!mounted) return;
if (inFlight) return;
inFlight = true;
if (!supabase) {
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([
supabase.from('horaires').select('id, *'),
supabase.from('perturbations').select('*'),
supabase.from('travaux').select('*')
]);
if (!mounted) { inFlight = false; return; }
if (hError || pError || tError) {
setError((hError || pError || tError).message || String(hError || pError || tError));
setLoading(false);
inFlight = false;
return;
}
debug('Supabase horaires fetched', { count: (horairesData || []).length });
if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) });
let parsedHoraires = (horairesData || []).map(h => h || {});
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) {}
return h;
});
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 en tant qu'ARRIVÉE
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 de départ (on veut les arrivées)
const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString());
if (departName && (departName === normalized || departName.includes(normalized) || normalized.includes(departName))) return false;
// Inclure si gare d'arrivée correspond
const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());
if (arriveeName && (arriveeName === normalized || arriveeName.includes(normalized) || normalized.includes(arriveeName))) return true;
// Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus d'origine (index==0)
const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) {
// compute max index to detect terminus positions
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 isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
if (isOrigin) 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 => {
const departId = (h.gare_depart_id || h.gare_depart || null);
if (departId != null && String(departId) === gid) return false;
const arriveeId = h.gare_arrivee_id || h.gare_arrivee || null;
if (arriveeId != null && String(arriveeId) === gid) return true;
const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) {
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 isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
if (isOrigin) continue;
if (String(stopId) === gid) return true;
}
}
return false;
});
}
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) }));
const now = new Date();
const todayHM = now.getHours()*60 + now.getMinutes();
const enrich = withDates.map(h => {
let raw = h.heure_arrivee || h.arrival_time || h.time || h.heure || '';
const stopsListForH = getStops(h);
if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) {
const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;
const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null;
const stop = byId || byName;
if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure;
}
const arrHM = parseTimeHM(raw);
const displayTime = formatTimeNoSeconds(raw);
return { ...h, _rawArrTime: raw, _arrHM: arrHM, _displayTime: displayTime };
});
// travaux processing (copié et adapté)
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();
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';
}
return (typeof v === 'undefined' || v === null) ? true : Boolean(v);
};
const travauxList = travauxData || [];
const parseDateOnly = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return new Date(d.getFullYear(), d.getMonth(), d.getDate()); };
const parseJoursTravaux = (val) => { if (val == null) return null; if (Array.isArray(val)) return new Set(val.map(x => String(x).toLowerCase())); const s = String(val || ''); const parts = s.split(/[,;\s]+/).map(p => p.trim()).filter(Boolean); return parts.length ? new Set(parts.map(p => p.toLowerCase())) : null; };
const weekdayShort = (i) => { switch(i) { case 0: return 'dim'; case 1: return 'lun'; case 2: return 'mar'; case 3: return 'mer'; case 4: return 'jeu'; case 5: return 'ven'; case 6: return 'sam'; default: return null; } };
const travauxApplies = (t, h, date) => {
if (!t) return false;
const db = parseDateOnly(t.date_debut || t.dateDebut || t.start_date);
const df = parseDateOnly(t.date_fin || t.dateFin || t.end_date);
if (!db || !df) return false;
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (d < db || d > df) return false;
const joursSet = parseJoursTravaux(t.jours_travaux || t.jours || t.days);
if (joursSet) {
const name = weekdayShort(d.getDay());
if (!name) return false;
let ok = false; for (const v of joursSet) { if (v.includes(name) || name.includes(v)) { ok = true; break; } }
if (!ok) return false;
}
const startHM = parseTimeHM(t.heure_debut || t.heureDebut || t.start_time);
const endHM = parseTimeHM(t.heure_fin || t.heureFin || t.end_time);
if (startHM == null || endHM == null) return true;
if (typeof h._arrHM === 'number') return h._arrHM >= startHM && h._arrHM <= endHM;
return false;
};
const isSubstitution = (h) => {
if (!h) return false;
if (h.substitution_disponible === true) return true;
const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true;
const subStr = (typeof h.substitution === 'string' && ['true','1','yes','y','t'].includes(h.substitution.toLowerCase()));
const typeMatch = h.type_train && /subst|substitut|remplac/i.test(String(h.type_train));
const commentMatch = h.comment && /subst|substitut|remplac/i.test(String(h.comment));
return Boolean(subFlag || subStr || typeMatch || commentMatch);
};
const enriched = enrich.map(h => {
let linked = false; let appliesToday = false; let appliesTomorrow = false;
for (const t of travauxList) {
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;
if (typeof tLine !== 'undefined' && tLine !== null) {
if (typeof hLine === 'undefined' || hLine === null) continue;
if (String(tLine) !== String(hLine)) 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 };
});
const todayCandidates = enriched.filter(h => {
if (h._date && h._date !== todayStr) return false;
if (!circulatesOn(h, today)) return false;
if (typeof h._arrHM !== 'number') return false;
if (h._arrHM <= todayHM) return false;
if (h._travauxAppliesToday && !isSubstitution(h)) return false;
if (isSubstitution(h)) 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;
if (h._travauxAppliesTomorrow && !isSubstitution(h)) return false;
if (isSubstitution(h)) return Boolean(h.substitution_disponible === true && h._linkedToTravaux && h._travauxAppliesTomorrow);
return true;
});
let finalList; let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); serviceDateStr = todayStr; }
else { finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); serviceDateStr = tomorrowStr; }
const localPerturbMap = buildPerturbMap(perturbData || [], parsedHoraires || []);
debug('localPerturbMap summary', {
horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length,
numberCount: Object.keys(localPerturbMap.byNumber || {}).length,
});
const annotated = applyPerturbationsToHoraires(finalList, perturbData || [], null, null, serviceDateStr);
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) {}
setHoraires((annotated || []).slice(0, 9));
setPerturbations(perturbData || []);
setTravaux(travauxData || []);
} catch (err) {
console.error('Erreur Supabase horaires (arrivees)', err);
if (mounted) setError(err.message || String(err));
} finally {
if (mounted) setLoading(false);
inFlight = false;
}
};
fetchData();
const intervalMs = 15000;
const intervalId = setInterval(fetchData, intervalMs);
return () => { mounted = false; clearInterval(intervalId); };
}, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, applyPerturbationsToHoraires, debug, getStops, parseTimeHM]);
return (
<div className="classiques-arrivees-container">
<div className="watermark-arrivees">arrivées</div>
<ul className="arrivees-list">
{horaires.length === 0 ? (
<li className="arrivee-row" 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 || '';
let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || '');
// afficher la gare de départ à la place de la destination
const departure = horaire.gare_depart_nom || horaire.gare_depart || '';
const platform = (horaire._displayPlatform || horaire.arrival_platform || horaire.platform || '').toString().trim();
const stationId = horaire.gare_arrivee_id || horaire.gare_arrivee || (gareIdParam || null);
const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_arrivee_nom || horaire.gare_arrivee || null);
const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));
if (status && status.label === 'supprimé' && showStatus) {
time = '';
} else if (!horaire._displayTime && status && typeof status.delayMinutes === 'number' && !isNaN(status.delayMinutes)) {
if (typeof horaire._arrHM === 'number') {
time = formatTimeFromHM(horaire._arrHM + status.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.heure_arrivee || horaire.arrival_time || horaire.time || horaire._rawArrTime || '');
if (typeof base === 'number') time = formatTimeFromHM(base + status.delayMinutes);
}
}
const statusClass = status && status.label === 'supprimé' ? 'status-suppressed-arrivees' : (status && status.label && String(status.label).startsWith('retardé') ? 'status-delayed-arrivees' : '');
// 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._arrHM === 'number') ? horaire._arrHM : parseTimeHM(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire._rawArrTime || '');
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="arrivee-row" key={idx}>
<div className="col-logo-arrivees"><Logo op={op} /></div>
<div className="col-train-type-arrivees">
{showStatus ? (
<div className={`train-meta-arrivees ${statusClass}`} aria-live="polite">
<div className="op-name-arrivees">{status ? status.label : ''}</div>
<div className="op-number-arrivees">{status ? status.detail : ''}</div>
</div>
) : (
<div className="train-meta-arrivees">
<div className="op-name-arrivees">{op}</div>
<div className="op-number-arrivees">{number}</div>
</div>
)}
</div>
<div className="col-time-arrivees"><div className="time-arrivees">{time}</div></div>
<div className="col-destination-arrivees"><div className="destination-arrivees">{departure}</div></div>
{showPlatform ? (
<div className="col-platform-arrivees"><div className="platform-box-arrivees"><div className="platform-text-arrivees">{platform}</div></div></div>
) : null}
</li>
);
})}
</ul>
<div className="afficheur-footer-arrivees" aria-hidden>
<div className="footer-inner-arrivees"><Clock /></div>
</div>
</div>
);
}
@@ -0,0 +1,398 @@
.aff-classiques-departs__container{
--blue-dark: #173771; /* bande foncée */
--blue: #275e9f; /* bande claire */
--accent-yellow: #e1c14c; /* heure */
--white: #f3f3f3;
--rows: 9; /* 9 horaires visibles + 1 footer */
--footer-height: 100px; /* hauteur de la dernière 'ligne' servant de footer */
--watermark-blue: rgba(10,120,180,0.15);
}
/* Déclaration des fontes Achemine (dans public/fonts/) */
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Conteneur plein écran pour l'afficheur */
.aff-classiques-departs__container{
position: relative;
width: 100%;
height: 100vh;
/* autoriser l'affichage des boîtes agrandies qui débordent des lignes */
overflow: visible;
font-family: "Achemine", "Helvetica Neue", Arial, sans-serif;
background: var(--blue-dark);
/* plus de padding-bottom : le footer sera superposé en position absolute */
}
/* Boîte affichant le nom de la gare configuré via ?gare=Nom-de-la-gare */
.aff-classiques-departs__station-box{
position: absolute;
left: 18px;
top: 12px;
color: #ffffff;
background: rgba(0,0,0,0.08);
padding: 6px 10px;
border-radius: 6px;
font-weight: 700;
z-index: 40;
}
/* arrondir légèrement le bas de l'afficheur pour coller à la maquette */
.aff-classiques-departs__container{
border-bottom-right-radius: 10px;
border-bottom-left-radius: 4px;
}
/* Liste des lignes (bandes horizontales) */
.aff-classiques-departs__list{
list-style: none;
margin: 0;
padding: 0;
/* La liste occupe toute la hauteur de l'écran; le footer sera affiché
en position absolue par-dessus la partie inférieure. */
height: 100vh;
display: flex;
flex-direction: column;
}
.aff-classiques-departs__row{
display: flex;
/* laisser les colonnes occuper toute la hauteur de la ligne, puis centrer leur contenu */
align-items: stretch;
box-sizing: border-box;
padding: 0 1.2vw;
/* Répartit l'espace en var(--rows) parts ; on utilise 100% / var(--rows) pour que
9 lignes + 1 footer occupent l'écran de façon uniforme. */
flex: 0 0 calc(100% / var(--rows));
min-height: 50px; /* garde une bonne hauteur sur petits écrans */
max-height: 120px;
position: relative; /* pour positionner la .platform-box en absolu */
overflow: visible; /* laisser la platform-box déborder si agrandie */
}
.aff-classiques-departs__row:nth-child(odd){
background: var(--blue);
}
.aff-classiques-departs__row:nth-child(even){
background: var(--blue-dark);
}
/* Colonne logo */
.aff-classiques-departs__col-logo{
max-width: 100px;
display:flex;
/* organiser verticalement les éléments et les centrer verticalement */
flex-direction: column;
justify-content: center; /* centre verticalement le contenu (logo + meta) */
align-items: flex-start; /* garder l'alignement horizontal à gauche */
flex: 0 0 10%; /* largeur fixe pour se comporter comme une colonne de tableau */
}
.aff-classiques-departs__logo-box{
/* pastille/logo : ne pas forcer la hauteur pour permettre l'empilement vertical */
height: auto;
width: 100px;
display:flex;
align-items:flex-start;
justify-content:center;
font-weight:800;
font-size: clamp(14px, 1.6vw, 20px);
box-sizing: border-box;
flex: 0 0 auto;
align-self: flex-start; /* centrer la pastille dans la colonne */
}
/* Logo SVG chargé depuis public/type */
.aff-classiques-departs__op-logo{
max-width: 90%;
max-height: 100%;
height: auto;
}
/* Texte à l'intérieur de la pastille/logo (quai) */
.aff-classiques-departs__logo-text{
text-align: center;
/* taille plus grande, responsive ; s'assure qu'elle reste lisible sur petits et grands écrans */
font-size: 20px;
line-height: 1;
letter-spacing: 0.5px;
}
/* Colonne heure */
.aff-classiques-departs__col-time{
max-width: 15%;
padding-left: 20px;
display:flex;
/* colonne verticale : centrer verticalement l'heure */
flex-direction: column;
align-items: center;
justify-content: center;
flex: 0 0 18%; /* largeur fixe pour alignement table-like */
}
.aff-classiques-departs__time{
color: var(--accent-yellow);
font-weight:850;
/* tailles responsives similaires à l'image */
font-size: clamp(26px, 7.5vw, 70px);
line-height: 1;
display: flex;
align-items: baseline; /* centre le chiffre verticalement */
height: auto;
width: 100%; /* occupe toute la colonne pour être centré */
text-align: center;
}
/* Destination */
.aff-classiques-departs__col-destination{
flex: 1 1 auto; /* colonne flexible qui prend l'espace restant */
display: flex;
/* colonne verticale : centrer verticalement la destination */
flex-direction: column;
justify-content: center;
align-items: flex-start; /* garder le texte aligné à gauche */
}
.aff-classiques-departs__destination{
color: var(--white);
font-weight: 500;
font-size: clamp(28px, 8vw, 80px);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
/* Colonne Platform (quai) affichée à droite de chaque ligne */
.aff-classiques-departs__col-platform{
/* La colonne laisse la boîte gérer sa taille ; on aligne la boîte à droite
et l'on centre verticalement pour qu'elle suive la hauteur de la ligne. */
display:flex;
flex: 0 0 8%;
align-items: center;
justify-content: flex-end;
padding-right: 0.5vw;
box-sizing: border-box;
}
.aff-classiques-departs__platform-box{
/* Boîte quai : garder un format carré mais ne pas forcer l'étirement vertical */
height: 90px; /* limite maximale, garde l'aspect visuel */
width: 90px;
aspect-ratio: 1 / 1; /* carré strict */
display:flex;
align-items:center;
justify-content:center;
background: transparent; /* intérieur transparent pour montrer la couleur de la ligne */
border-radius: 12px;
box-sizing: border-box;
border: 4px solid #ffffff; /* bord blanc épais */
padding: 8px; /* espace intérieur pour éloigner le texte du bord */
flex: 0 0 auto;
align-self: center;
}
.aff-classiques-departs__platform-text{
color: #ffffff;
font-weight: 900;
/* réduire légèrement la taille du texte afin qu'il ne touche pas la bordure */
font-size: clamp(60px, 2.6vw, 100px);
line-height:1;
letter-spacing: 0.8px;
}
/* Colonne Type de train (victoire de la séparation avec la colonne logo) */
.aff-classiques-departs__col-train-type{
max-width: 9%;
display:flex;
/* colonne verticale : centrer verticalement l'heure */
flex-direction: column;
align-items: stretch;
justify-content: center;
flex: 0 0 18%; /* largeur fixe pour alignement table-like */
}
.aff-classiques-departs__train-type-box{
height: auto;
aspect-ratio: 1 / 1;
display:flex;
align-items:center;
justify-content:center;
background: transparent;
border-radius: 10px;
box-sizing: border-box;
}
.aff-classiques-departs__train-type-fallback{ text-align:center; }
/* Numéro de train / opérateur (texte sous logo) */
.aff-classiques-departs__train-meta{
flex-direction:column;
align-items:flex-start;
/* centrer verticalement sans forcer la hauteur (pour permettre l'empilement) */
justify-content: center;
color: #ffffff;
height: auto;
flex: 0 0 auto;
}
.aff-classiques-departs__train-meta .aff-classiques-departs__op-name{
font-weight:900;
font-size:1.50rem; /* plus visible */
padding-left: 20px;
line-height:1;
justify-content: flex-start;
text-align: left; /* aligner à gauche dans la colonne */
}
.aff-classiques-departs__train-meta .aff-classiques-departs__op-number{
font-weight:500;
font-size:1.25rem;
padding-left: 20px;
text-align: left; /* aligner à gauche */
margin: 0 0 2px 0;
}
/* Statuts spéciaux : retardé / supprimé doivent ressortir en jaune */
.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--delayed .aff-classiques-departs__op-name,
.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--delayed .aff-classiques-departs__op-number,
.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--suppressed .aff-classiques-departs__op-name,
.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--suppressed .aff-classiques-departs__op-number{
color: var(--accent-yellow);
}
/* Si on veut un alignement plus spécifique quand train-meta apparaît dans la colonne de type */
.aff-classiques-departs__col-train-type .aff-classiques-departs__train-meta{ align-items: flex-start; justify-content: center; }
.aff-classiques-departs__col-train-type .aff-classiques-departs__train-meta .aff-classiques-departs__op-name,
.aff-classiques-departs__col-train-type .aff-classiques-departs__train-meta .aff-classiques-departs__op-number{ text-align: left; }
/* Watermark vertical à droite */
.aff-classiques-departs__watermark{
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%) rotate(-90deg);
transform-origin: center;
font-size: 10.8vw;
color: var(--watermark-blue);
font-weight:800;
letter-spacing: 6px;
pointer-events: none;
user-select: none;
}
/* Petite pastille H en haut à droite (optionnelle) */
.aff-classiques-departs__helpline{
position: absolute;
right: 18px;
top: 12px;
width:80px;
height:80px;
border-radius:6px;
border:3px solid #fff;
color:#fff;
display:flex;
align-items:center;
justify-content:center;
font-weight:900;
}
/* Horloge en bas à droite, proche du bord droit (à 400px du bord droit) */
.aff-classiques-departs__clock-container{
position: relative; /* contenu positionné par le footer */
pointer-events: none;
z-index: 30; /* au-dessus du footer */
display: inline-flex;
align-items: center;
height: 100%; /* prendra la hauteur de la ligne footer */
}
.aff-classiques-departs__clock-box{
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--blue-dark); /* fond foncé interne comme sur la maquette */
border-radius: 10px;
padding: 4px 8px; /* réduire padding pour diminuer légèrement la taille globale */
border: 6px solid #ffffff; /* bordure un peu moins épaisse */
box-shadow: 0 2px 0 rgba(0,0,0,0.12);
box-sizing: border-box;
overflow: hidden;
/* hauteur adaptée pour rentrer exactement dans la ligne footer */
height: calc(var(--footer-height) - 12px);
min-height: 34px;
}
/* Footer visuel : barre foncée en bas qui chevauche la liste */
.aff-classiques-departs__footer{
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--footer-height);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 20; /* en dessous de l'horloge */
}
.aff-classiques-departs__footer .aff-classiques-departs__footer-inner{
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center; /* centre verticalement le contenu (la clock) */
justify-content: flex-end; /* place la clock à droite */
padding-right: 12px; /* espace entre le bord et la clock pour le coin arrondi */
/* léger dégradé pour donner l'effet de bande plus claire en haut */
background: linear-gradient(to bottom, rgba(11,123,180,0.18) 0%, var(--blue-dark) 30%);
box-shadow: inset 0 6px 0 rgba(0,0,0,0.08);
/* arrondir côté droit comme sur la maquette */
}
/* légère ligne (accent) en haut du footer pour séparer visuellement */
.aff-classiques-departs__footer .aff-classiques-departs__footer-inner::before{
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 8px;
background: rgba(255,255,255,0.03);
pointer-events: none;
border-top-right-radius: 12px;
}
.aff-classiques-departs__clock-main{
color: #ffffff;
font-weight: 800;
/* légèrement plus petit pour tenir dans le footer */
font-size: clamp(28px, 4.2vw, 60px);
line-height: 1;
font-family: "Achemine", "Helvetica Neue", Arial, sans-serif;
margin-right: 6px;
}
.aff-classiques-departs__clock-seconds{
color: var(--accent-yellow);
font-weight: 800;
font-size: clamp(16px, 2.8vw, 36px);
line-height: 1;
}
/* Ajustements pour petits écrans */
@media (max-width: 700px){
.aff-classiques-departs__col-logo{width: 18%;}
.aff-classiques-departs__col-time{width: 24%;}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,475 @@
.achemine-font-face {
/* helper placeholder (no output) */
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
.aff-quais__left--strict {
width: 100%;
max-width: 0;
min-width: 700px;
background: var(--white);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
/* Remonter l'ensemble du contenu vers le bas pour laisser la place au logo en haut */
padding: 140px 32px 24px 32px;
box-sizing: border-box;
position: relative;
/* leave space at bottom for the footer (height 86px) */
height: calc(100vh - 86px);
overflow: hidden;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
}
.aff-quais__left--strict,
.aff-quais__left--strict * {
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
/* Ensure the left strict zone text uses the intended color (fallback to green-dark) */
color: #2d522c;
}
.aff-quais__type-logo--strict{
position: absolute;
top: 18px;
/* positionné à gauche dans la zone blanche (à droite du logo opérateur) */
/* tout à gauche de la zone blanche */
left: 32px;
width: 120px;
height: auto;
object-fit: contain;
z-index: 5;
pointer-events: none;
}
.aff-quais__sncf-logo--strict {
width: 86px;
height: auto;
margin-bottom: 18px;
margin-left: 0;
margin-top: 0;
position: absolute;
top: 18px;
/* placé à droite du logo du type */
left: 140px;
z-index: 4;
}
.aff-quais__watermark {
position: absolute;
/* move watermark further to the right */
right: -180px;
top: 70%;
transform: translateY(-50%) rotate(-90deg);
/* police agrandie de 200% */
font-size: 200px;
color: var(--watermark-green);
opacity: 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: var(--green-dark);
font-weight: 800;
/* police agrandie de 200% */
font-size: 112px;
line-height: 1;
letter-spacing: 0.5px;
margin-right: 12px;
}
/* Wrapper that contains both the big time and the smaller status when alternating.
Keeps the layout width/height stable so alternating does not reflow the surrounding text. */
.aff-quais__time-status-wrapper {
position: relative;
display: inline-block; /* width is determined by the time element in normal flow */
height: 112px; /* match .aff-quais__time font-size so status can be aligned at bottom */
vertical-align: bottom;
overflow: visible;
}
.aff-quais__time-status-wrapper .aff-quais__time--strict {
position: relative; /* keep in flow to define wrapper width */
display: block;
top: 0;
left: 0;
transition: opacity 180ms linear;
}
.aff-quais__time-status-wrapper .aff-quais__time-status--strict {
position: absolute; /* overlay on the same horizontal position */
left: 0;
right: 0;
bottom: 0; /* align to bottom similar to previous align-self:flex-end */
transition: opacity 180ms linear;
}
.aff-quais__time-status-wrapper .hidden { opacity: 0; pointer-events: none; }
.aff-quais__time-status-wrapper .visible { opacity: 1; }
.aff-quais__time-status--strict {
color: var(--accent-yellow);
/* police agrandie de 200% */
font-size: 44px;
font-weight: 400;
opacity: 1;
align-self: flex-end;
margin-bottom: 14px;
}
.aff-quais__destination--strict {
margin-top: 10px;
margin-bottom: 0;
color: var(--green-dark);
z-index: 1;
}
.aff-quais__destination-line1--strict {
/* police agrandie de 200% */
font-size: 76px;
font-weight: 800;
line-height: 1.1;
}
.aff-quais__destination-line2--strict {
/* police agrandie de 200% */
font-size: 76px;
font-weight: 800;
line-height: 1.1;
}
.aff-quais__train-id--strict {
margin-top: 12px;
color: var(--green-dark);
font-weight: 700;
/* police agrandie de 200% */
font-size: 44px;
letter-spacing: 0.5px;
z-index: 1;
}
.aff-quais__train-number--strict {
color: var(--green-dark);
font-weight: 400;
/* police agrandie de 200% */
font-size: 44px;
}
.aff-quais__quai{
margin-top: 8px;
color: var(--green-dark);
font-weight:800;
background: rgba(255,255,255,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,var(--green) 0%, var(--green-dark) 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: #bbd258; /* 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;
/* use arrivals green gradient */
background: linear-gradient(180deg, var(--green) -100%, var(--green-dark) 100%);
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 48px 0 48px 0;
min-width: 0;
/* leave space for footer */
height: calc(100vh - 86px);
box-sizing: border-box;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
position: relative; /* anchor for the stops rail */
}
.aff-quais__stops-list--strict {
list-style: none;
margin: 0;
/* variables pour contrôler la colonne de pastilles */
--rail-gap: 72px; /* espacement vertical entre pastilles (ajuster si nécessaire) */
--rail-offset: 24px; /* décalage vertical initial pour aligner la première pastille */
--rail-left: 28px; /* position X du rail inside right panel */
--rail-width: 12px; /* largeur du rail/pastille */
--ul-padding: 56px; /* padding-left actuel de la liste */
/* laisser un espace modéré à gauche pour la colonne de points */
padding: 0 0 0 var(--ul-padding);
width: 100%;
max-width: 600px;
position: relative;
}
/* élément rail explicite (utilisé pour afficher la colonne de pastilles) */
.aff-quais__stops-rail{
position: absolute;
/* position relative to the right panel container */
left: calc(var(--rail-left) - 8px);
top: 0;
bottom: 0;
/* make rail clearly visible and wide enough */
width: 30px;
z-index: 2;
pointer-events: none;
/* base color: same as destination rectangle */
background-color: rgba(255,255,255,0.06);
overflow: visible;
}
.aff-quais__rail-dot{
display: block;
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
/* size is set dynamically from JS to match rail width */
width: auto;
height: auto;
border-radius: 50%;
background: var(--accent-yellow);
box-shadow: 0 0 0 4px rgba(255,230,102,0.06) inset;
}
.aff-quais__stop--strict {
display: flex;
align-items: center;
/* agrandi de 150% */
font-size: calc(2.1rem * 1.5);
color: #fff;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
font-weight: 400;
/* espacement augmenté entre les noms des gares */
margin-bottom: 18px;
line-height: 1.18;
padding: 0 0 0 0;
position: relative; /* each li is positioned so its ::before aligns with the rail */
}
.aff-quais__stop-bullet--strict {
/* bullets individuels masqués : on utilise une barre verticale */
display: none;
}
/* pastille positionnée pour chaque item, alignée sur la colonne (rail) */
.aff-quais__stop--strict::before{
/* disable per-item pseudo pastille — rail handles the dots */
display: none;
}
/* barre verticale de points jaunes pour la liste des gares */
/* removed ::before — using explicit .aff-quais__stops-rail element instead */
.aff-quais__stop-text--strict{ /* ensure text doesn't overlap the rail */
padding-left: 6px;
}
.aff-quais__status--alert{
color: #ffe600;
font-weight: 800;
}
.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: rgba(255,255,255,0.08);
color: var(--green-dark);
border-radius: 6px;
padding: 2px 32px 2px 0;
margin-left: -45px;
width: auto;
padding-left: 45px;
}
/* Ensure the highlighted destination stop text is white for readability */
.aff-quais__stop--strict-dest .aff-quais__stop-text--strict {
color: #ffffff !important;
}
.aff-quais__container--strict.aff-quais__container--arrivees {
display: flex;
flex-direction: row;
width: 100vw;
height: 100vh;
min-height: 0;
min-width: 0;
background: var(--green-dark);
box-sizing: border-box;
overflow: hidden;
/* make container positioned so the absolute footer can be placed relative to it */
position: relative;
}
.aff-quais__separator--strict {
width: 14px;
min-width: 14px;
max-width: 14px;
background: var(--green-dark);
/* align separator with the panels which leave room for footer */
height: calc(100vh - 86px);
display: block;
z-index: 2;
}
@media (max-width: 900px) {
.aff-quais__right--strict {
padding: 18px 0 18px 0;
}
/* moins d'espace en haut sur petits écrans */
.aff-quais__left--strict { padding-top: 80px; }
.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;
}
/* CLOCK BOX - unified */
.aff-quais__clock-box{
background: var(--green-dark);
color:#fff;
padding: 6px 12px;
border-radius: 6px;
border: 4px solid #ffffff;
}
.aff-quais__clock-main{
font-size:18px;
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;
}
@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}
}
@@ -0,0 +1,871 @@
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>
);
}
@@ -0,0 +1,488 @@
/* Partie gauche strictement comme l'image */
.achemine-font-face {
/* helper placeholder (no output) */
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
.aff-quais__left--strict {
width: 100%;
max-width: 0;
min-width: 700px;
background: #fff;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
/* Remonter l'ensemble du contenu vers le bas pour laisser la place au logo en haut */
padding: 140px 32px 24px 32px;
box-sizing: border-box;
position: relative;
/* leave space at bottom for the footer (height 86px) */
height: calc(100vh - 86px);
overflow: hidden;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
}
.aff-quais__left--strict,
.aff-quais__left--strict * {
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
}
/* Apply blue color specifically for departures container only (avoid being overridden by arrivées stylesheet)
Use :not(.aff-quais__container--arrivees) to scope to departures */
.aff-quais__container--strict:not(.aff-quais__container--arrivees) .aff-quais__left--strict,
.aff-quais__container--strict:not(.aff-quais__container--arrivees) .aff-quais__left--strict * {
color: #0d4a87;
}
/* Strong override for departures container: ensure destination title isn't black/green due to other styles */
.aff-quais__container--depart .aff-quais__left--strict,
.aff-quais__container--depart .aff-quais__left--strict * {
color: #0d4a87 !important;
}
.aff-quais__type-logo--strict{
position: absolute;
top: 18px;
/* positionné à gauche dans la zone blanche (à droite du logo opérateur) */
/* tout à gauche de la zone blanche */
left: 32px;
width: 120px;
height: auto;
object-fit: contain;
z-index: 5;
pointer-events: none;
}
.aff-quais__sncf-logo--strict {
width: 86px;
height: auto;
margin-bottom: 18px;
margin-left: 0;
margin-top: 0;
position: absolute;
top: 18px;
/* placé à droite du logo du type */
left: 140px;
z-index: 4;
}
.aff-quais__watermark {
position: absolute;
/* move watermark further to the right */
right: -180px;
top: 70%;
transform: translateY(-50%) rotate(-90deg);
/* police agrandie de 200% */
font-size: 200px;
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;
/* police agrandie de 200% */
font-size: 112px;
line-height: 1;
letter-spacing: 0.5px;
margin-right: 12px;
}
/* Wrapper that contains both the big time and the smaller status when alternating.
Keeps the layout width/height stable so alternating does not reflow the surrounding text. */
.aff-quais__time-status-wrapper {
position: relative;
display: inline-block; /* width is determined by the time element in normal flow */
height: 112px; /* match .aff-quais__time font-size so status can be aligned at bottom */
vertical-align: bottom;
overflow: visible;
}
.aff-quais__time-status-wrapper .aff-quais__time--strict {
position: relative; /* keep in flow to define wrapper width */
display: block;
top: 0;
left: 0;
transition: opacity 180ms linear;
}
.aff-quais__time-status-wrapper .aff-quais__time-status--strict {
position: absolute; /* overlay on the same horizontal position */
left: 0;
right: 0;
bottom: 0; /* align to bottom similar to previous align-self:flex-end */
transition: opacity 180ms linear;
}
.aff-quais__time-status-wrapper .hidden { opacity: 0; pointer-events: none; }
.aff-quais__time-status-wrapper .visible { opacity: 1; }
.aff-quais__time-status--strict {
color: #0d4a87;
/* police agrandie de 200% */
font-size: 44px;
font-weight: 400;
opacity: 1;
align-self: flex-end;
margin-bottom: 14px;
}
.aff-quais__destination--strict {
margin-top: 10px;
margin-bottom: 0;
color: #0d4a87;
z-index: 1;
}
.aff-quais__destination-line1--strict {
/* police agrandie de 200% */
font-size: 76px;
font-weight: 800;
line-height: 1.1;
}
.aff-quais__destination-line2--strict {
/* police agrandie de 200% */
font-size: 76px;
font-weight: 800;
line-height: 1.1;
}
.aff-quais__train-id--strict {
margin-top: 12px;
color: #0d4a87;
font-weight: 700;
/* police agrandie de 200% */
font-size: 44px;
letter-spacing: 0.5px;
z-index: 1;
}
.aff-quais__train-number--strict {
color: #0d4a87;
font-weight: 400;
/* police agrandie de 200% */
font-size: 44px;
}
.aff-quais__quai{
margin-top: 8px;
color:#0d4a87;
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(255, 255, 255, 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: #bbd258; /* 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;
/* leave space for footer */
height: calc(100vh - 86px);
box-sizing: border-box;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
position: relative; /* anchor for the stops rail */
}
.aff-quais__stops-list--strict {
list-style: none;
margin: 0;
/* variables pour contrôler la colonne de pastilles */
--rail-gap: 72px; /* espacement vertical entre pastilles (ajuster si nécessaire) */
--rail-offset: 24px; /* décalage vertical initial pour aligner la première pastille */
--rail-left: 28px; /* position X du rail inside right panel */
--rail-width: 12px; /* largeur du rail/pastille */
--ul-padding: 56px; /* padding-left actuel de la liste */
/* laisser un espace modéré à gauche pour la colonne de points */
padding: 0 0 0 var(--ul-padding);
width: 100%;
max-width: 600px;
position: relative;
}
/* élément rail explicite (utilisé pour afficher la colonne de pastilles) */
.aff-quais__stops-rail{
position: absolute;
/* position relative to the right panel container */
left: calc(var(--rail-left) - 8px);
top: 0;
bottom: 0;
/* make rail clearly visible and wide enough */
width: 30px;
z-index: 2;
pointer-events: none;
/* base color: same as destination rectangle */
background-color: #b6d2f7;
overflow: visible;
}
.aff-quais__rail-dot{
display: block;
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
/* size is set dynamically from JS to match rail width */
width: auto;
height: auto;
border-radius: 50%;
background: #b9d25a;
box-shadow: 0 0 0 4px rgba(255,230,102,0.06) inset;
}
.aff-quais__stop--strict {
display: flex;
align-items: center;
/* agrandi de 150% */
font-size: calc(2.1rem * 1.5);
color: #fff;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
font-weight: 400;
/* espacement augmenté entre les noms des gares */
margin-bottom: 18px;
line-height: 1.18;
padding: 0 0 0 0;
position: relative; /* each li is positioned so its ::before aligns with the rail */
}
.aff-quais__stop-bullet--strict {
/* bullets individuels masqués : on utilise une barre verticale */
display: none;
}
/* pastille positionnée pour chaque item, alignée sur la colonne (rail) */
.aff-quais__stop--strict::before{
/* disable per-item pseudo pastille — rail handles the dots */
display: none;
}
/* barre verticale de points jaunes pour la liste des gares */
/* removed ::before — using explicit .aff-quais__stops-rail element instead */
.aff-quais__stop-text--strict{ /* ensure text doesn't overlap the rail */
padding-left: 6px;
}
.aff-quais__status--alert{
color: #ffe600;
font-weight: 800;
}
.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 32px 2px 0;
margin-left: -45px;
width: auto;
padding-left: 45px;
}
/* Ensure the highlighted destination stop text is always white for readability */
.aff-quais__stop--strict-dest .aff-quais__stop-text--strict {
color: #ffffff !important;
}
.aff-quais__container--strict {
display: flex;
flex-direction: row;
width: 100vw;
height: 100vh;
min-height: 0;
min-width: 0;
/* Palette locale pour l'afficheur départs : évite la palette arrivées importée ailleurs */
--blue: #205aa1;
--blue-dark: #144b85;
--accent-yellow: #e1c14c;
--white: #ffffff;
--watermark-blue: rgba(23,55,113,0.07);
background: var(--blue);
box-sizing: border-box;
overflow: hidden;
/* make container positioned so the absolute footer can be placed relative to it */
position: relative;
}
.aff-quais__separator--strict {
width: 14px;
min-width: 14px;
max-width: 14px;
background: #205aa1;
/* align separator with the panels which leave room for footer */
height: calc(100vh - 86px);
display: block;
z-index: 2;
}
@media (max-width: 900px) {
.aff-quais__right--strict {
padding: 18px 0 18px 0;
}
/* moins d'espace en haut sur petits écrans */
.aff-quais__left--strict { padding-top: 80px; }
.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;
}
/* CLOCK BOX - unified */
.aff-quais__clock-box{
background: rgba(23,55,113,0.9);
color:#fff;
padding: 6px 12px;
border-radius: 6px;
border: 4px solid #ffffff;
}
.aff-quais__clock-main{
font-size:18px;
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;
}
@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}
}
@@ -0,0 +1,898 @@
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>
);
}