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

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

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

853 lines
36 KiB
JavaScript

import React, { useEffect, useState, useCallback } from 'react';
import './ClassiquesDepartsQuais.css';
import supabase from '../../../../lib/supabase';
import { fetchAllRows } from '../../../../lib/fetchAllRows';
import useAnnonceSonoreRealtime from '../../../../hooks/useAnnonceSonoreRealtime';
import useAutoAnnonce from '../../../../hooks/useAutoAnnonce';
import { getSourceFromPath } from '../../../../services/railwayService';
import { getSncfTimetable } from '../../../../services/sncfApi';
import { matchStationNameStrict } from '../../../../services/gtfsStations';
import GareVideoprotection from '../../../../components/afficheurs/GareVideoprotection';
import AttentifsEnsemble from '../../../../components/afficheurs/AttentifsEnsemble';
import DepartEnCoursClassique from '../../../../components/afficheurs/DepartEnCoursClassique';
import ClassiquesInformations from '../ClassiquesInformations';
import ClassiquesDeparts from '../ClassiquesDeparts';
import TgvCompositionQuai from '../../../../components/afficheurs/TgvCompositionQuai';
function TgvServiceIcons() {
return (
<div className="aff-classique-quai-dep__services">
{/* PMR / Fauteuil roulant */}
<svg width="30" height="30" viewBox="0 0 24 24" fill="#7e859b">
<path d="M12 2a2 2 0 100 4 2 2 0 000-4zm-2 6a1 1 0 00-1 1v6a1 1 0 001 1h4a1 1 0 001-1v-2a1 1 0 00-2 0v1h-2v-4h3a1 1 0 001-1V9a1 1 0 00-1-1H10zm-1 9a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4z"/>
</svg>
{/* Vélo */}
<svg width="34" height="30" viewBox="0 0 24 24" fill="none" stroke="#7e859b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="5.5" cy="17.5" r="3.5"/>
<circle cx="18.5" cy="17.5" r="3.5"/>
<path d="M15 6a1 1 0 100-2 1 1 0 000 2zm-3 11.5L9 11.5l3-3.5h3.5m-6.5 7L12 8"/>
</svg>
{/* Bar / Verre */}
<svg width="28" height="30" viewBox="0 0 24 24" fill="none" stroke="#7e859b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4L12 14L20 4H4Z" fill="#7e859b" />
<path d="M12 14V21M8 21H16" />
</svg>
</div>
);
}
function DigitalClock() {
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-classique-quai-dep__clock">
<span className="aff-classique-quai-dep__clock-hm">{hh}:{mm}</span>
<span className="aff-classique-quai-dep__clock-s">{ss}</span>
</div>
);
}
function getTrainBrand(trainType = '', operator = '', isDeparture = true) {
const combined = `${trainType || ''} ${operator || ''}`.toLowerCase().trim();
if (combined.includes('zou')) {
return { logo: '/type/quais/logo-zou-color.svg', label: 'ZOU!', showVoyageursText: false };
}
if (combined.includes('fluo')) {
return { logo: '/type/quais/logo-fluo-color.svg', label: 'TER Fluo', showVoyageursText: false };
}
if (combined.includes('aleop') || combined.includes('al\u00e9op')) {
return { logo: '/type/quais/logo-aleop-color.svg', label: 'Aléop', showVoyageursText: false };
}
if (combined.includes('breizh')) {
return { logo: '/type/quais/logo-breizhgo-color.svg', label: 'BreizhGo', showVoyageursText: false };
}
if (combined.includes('lio') || combined.includes('li\u00f4')) {
return { logo: '/type/quais/logo-lio-color.svg', label: 'liO', showVoyageursText: false };
}
if (combined.includes('mobigo')) {
return { logo: '/type/quais/logo-mobigo-color.svg', label: 'Mobigo', showVoyageursText: false };
}
if (combined.includes('nomad')) {
return { logo: '/type/quais/logo-nomad-color.svg', label: 'Nomad', showVoyageursText: false };
}
if (combined.includes('remi') || combined.includes('r\u00e9mi')) {
return { logo: '/type/quais/logo-remi-color.svg', label: 'Rémi', showVoyageursText: false };
}
if (combined.includes('inoui') || combined.includes('tgv inoui')) {
return { logo: '/type/quais/logo-inoui-color.svg', label: 'TGV INOUI', showVoyageursText: false };
}
if (combined.includes('ouigo classique')) {
return { logo: '/type/quais/logo-ouigo-classique-color.svg', label: 'OUIGO Classique', showVoyageursText: false };
}
if (combined.includes('ouigo')) {
return { logo: '/type/quais/logo-ouigo-color.svg', label: 'OUIGO', showVoyageursText: false };
}
if (combined.includes('intercite') || combined.includes('intercit\u00e9')) {
return { logo: '/type/quais/logo-intercite-color.svg', label: 'INTERCITÉS', showVoyageursText: false };
}
if (combined.includes('tgv')) {
return { logo: '/type/quais/logo-tgv-color.svg', label: 'TGV', showVoyageursText: false };
}
if (combined.includes('eurostar')) {
return { logo: '/type/quais/logo-eurostar-color.svg', label: 'Eurostar', showVoyageursText: false };
}
if (combined.includes('freccia') || combined.includes('trenitalia')) {
return { logo: '/type/quais/logo-frecciarossa-color.svg', label: 'Frecciarossa', showVoyageursText: false };
}
if (combined.includes('ice') || combined.includes('db')) {
return { logo: '/type/quais/logo-ice-color.svg', label: 'ICE', showVoyageursText: false };
}
if (combined.includes('ter')) {
return { logo: '/type/quais/logo-ter-color.svg', label: 'TER', showVoyageursText: false };
}
// Par défaut Départs : Logo SNCF Voyageurs (Image 1)
if (isDeparture) {
return { logo: '/type/quais/logo-sncf-color.svg', label: trainType || 'SNCF', showVoyageursText: true };
}
return { logo: '/type/quais/logo-ter-color.svg', label: trainType || 'TER', showVoyageursText: false };
}
export default function ClassiquesDepartsQuais() {
const searchParams = new URLSearchParams(window.location.search || '');
let gareParam = searchParams.get('gare') || searchParams.get('station') || '';
const gareIdParam = searchParams.get('gare_id') || searchParams.get('gareId') || null;
const quaiParam = searchParams.get('quai') || searchParams.get('voie') || searchParams.get('track') || searchParams.get('platform') || '';
// Fallback si la gare est dans le chemin
if (!gareParam && typeof window !== 'undefined') {
const cleanPath = window.location.pathname.replace(/^\/ferrovia/, '');
const parts = cleanPath.split('/').filter(Boolean);
const qIdx = parts.findIndex(p => p === 'quais');
if (qIdx >= 0 && parts[qIdx + 1]) {
gareParam = parts.slice(qIdx + 1).join('/');
}
}
const gareDisplay = gareParam
? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ')
: null;
const mock = {
time: '09h11',
status: "à l'heure",
trainType: 'ZOU!',
trainNumber: '881027',
destination: 'Aubagne',
stops: [
'Marseille Blancarde',
'La Pomme',
'St Marcel',
'La Barasse',
'La Penne sur Huveaune',
'Aubagne'
]
};
const [selectedHoraire, setSelectedHoraire] = useState(null);
const [, setLoadingHoraire] = useState(false);
const [, setHoraireError] = useState(null);
const [currentTime, setCurrentTime] = useState(new Date());
const [idlePhase, setIdlePhase] = useState(0); // 0 = Attentifs (20s), 1 = Quai (20s), 2 = Information (10s)
const [showStatusAlt, setShowStatusAlt] = useState(false);
const pageParam = (searchParams.get('page') || searchParams.get('mode') || '').toLowerCase();
const isVideoprotection = pageParam === 'videoprotection' || searchParams.get('videoprotection') === 'true';
const isForceDepartEnCours = pageParam === 'depart_en_cours' || searchParams.get('depart_en_cours') === 'true' || searchParams.get('test_depart') === 'true';
const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null);
useAnnonceSonoreRealtime(resolvedGareId, 'ClassiquesDepartsQuais', quaiParam);
useAutoAnnonce(selectedHoraire ? [selectedHoraire] : [], currentTime, false);
// Horloge 1s
useEffect(() => {
const id = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
// Alternance 3 secondes : Heure normale ↔ Statut (pour trains retardés ou supprimés)
useEffect(() => {
const id = setInterval(() => setShowStatusAlt(prev => !prev), 3000);
return () => clearInterval(id);
}, []);
// Alternance par défaut (20s Attentifs -> 20s Quai -> 10s Information) si aucun train n'est prévu sur le quai
useEffect(() => {
if (selectedHoraire) return;
let duration = 20000;
if (idlePhase === 2) duration = 10000;
else duration = 20000;
const timer = setTimeout(() => {
setIdlePhase(prev => (prev + 1) % 3);
}, duration);
return () => clearTimeout(timer);
}, [idlePhase, selectedHoraire]);
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 cleanPlatform = useCallback((p) => {
if (!p) return '';
return String(p)
.toLowerCase()
.replace(/^(?:voie|quai|track|platform)\s*/i, '')
.replace(/\s+/g, '')
.trim();
}, []);
const parseTimeHM = useCallback((s) => {
if (!s) return null;
if (typeof s === 'number') return s % 1440;
const m = String(s).match(/(\d{1,2})[:h](\d{2})/i);
if (m) {
const h = parseInt(m[1], 10) % 24;
return h * 60 + parseInt(m[2], 10);
}
const d = new Date(s);
if (!isNaN(d.getTime())) return d.getHours() * 60 + d.getMinutes();
return null;
}, []);
const formatTimeNoSeconds = useCallback((v) => {
if (v == null) return '';
const tryDate = new Date(v);
if (!isNaN(tryDate.getTime())) {
const hh = String(tryDate.getHours() % 24).padStart(2, '0');
const mm = String(tryDate.getMinutes()).padStart(2, '0');
return `${hh}h${mm}`;
}
if (typeof v === 'string') {
const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);
if (m) {
const h = parseInt(m[1], 10) % 24;
return `${String(h).padStart(2, '0')}h${m[2]}`;
}
const m2 = v.match(/T(\d{2}):(\d{2})/);
if (m2) {
const h = parseInt(m2[1], 10) % 24;
return `${String(h).padStart(2, '0')}h${m2[2]}`;
}
const m3 = v.match(/(\d{1,2})h(\d{2})/i);
if (m3) {
const h = parseInt(m3[1], 10) % 24;
return `${String(h).padStart(2, '0')}h${m3[2]}`;
}
return v.slice(0, 5).replace(':', 'h');
}
return String(v).slice(0, 5).replace(':', 'h');
}, []);
const circulatesOn = useCallback((h, d) => {
if (!h || !d) return false;
const pad = (n) => String(n).padStart(2, '0');
const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
const targetDay = d.getDay();
if (Array.isArray(h.dates) && h.dates.length > 0) {
return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen);
}
const customDaysRaw = h.jours_personnalises;
if (customDaysRaw) {
let customDays = [];
if (Array.isArray(customDaysRaw)) customDays = customDaysRaw;
else if (typeof customDaysRaw === 'string') {
try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
}
if (Array.isArray(customDays) && customDays.length > 0) {
const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact);
if (match) return Boolean(match.circule);
}
}
const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const colName = `circule_${dayNames[targetDay]}`;
if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null;
const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null;
if (db && targetDateHyphen < db) return false;
if (df && targetDateHyphen > df) return false;
return Boolean(h[colName]);
}
if (Array.isArray(h.days) && h.days.length > 0) {
if (!h.days.includes(targetDay)) return false;
if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false;
if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false;
return true;
}
const singleDate = h.date_depart || h.date || h.service_date;
if (singleDate) {
const sStr = String(singleDate).slice(0, 10);
return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact;
}
return true;
}, []);
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', 'arrets'];
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) {}
}
}
return [];
}, []);
// Chargement du train au départ
const loadNextForQuai = useCallback(async () => {
setLoadingHoraire(true);
setHoraireError(null);
const source = getSourceFromPath(window.location.pathname);
const targetQuaiClean = cleanPlatform(quaiParam);
let chosenTrain = null;
// 1. Tenter la source SNCF si applicable
if (source === 'sncf' || !supabase) {
try {
const { data: sncfData } = await getSncfTimetable({
gareName: gareDisplay || gareParam,
mode: 'departs',
quai: quaiParam
});
if (sncfData && sncfData.length > 0) {
let matchingTrains = sncfData;
if (targetQuaiClean) {
matchingTrains = sncfData.filter(t => cleanPlatform(t._displayPlatform || t.quai || t.voie || t.quai_reel) === targetQuaiClean);
}
const train = matchingTrains[0];
if (train) {
const depHM = parseTimeHM(train.heure_depart || train.time || train.heure);
const trainPlatform = train._displayPlatform || train.quai || train.voie || quaiParam || '';
const trainNumClean = (train.numero_train || train.num || '').toString();
// Recherche d'une deuxième tranche / train couplé au même horaire
const partner = matchingTrains.find(t => {
if (!t || t === train) return false;
const tNum = (t.numero_train || t.num || '').toString();
const tHM = parseTimeHM(t.heure_depart || t.time || t.heure);
return tNum !== trainNumClean && tHM === depHM;
});
chosenTrain = {
horaire: {
...train,
quai: trainPlatform,
quai_reel: trainPlatform,
voie: trainPlatform,
_displayPlatform: trainPlatform,
_status: train._status || {
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure"),
detail: '',
delayMinutes: train.retard_minutes || null,
causeDetails: train.cause_retard || null
}
},
partner: partner || null,
stop: {
gare_nom: train.gare_depart_nom || gareDisplay || gareParam,
heure_depart: train.heure_depart || train.time || train.heure,
quai: trainPlatform
},
depHM: depHM != null ? depHM : 0,
rawTime: train.heure_depart || train.time || train.heure
};
}
}
} catch (e) {
console.warn('Notice getSncfTimetable:', e);
}
}
// 2. Si pas trouvé via SNCF, ou si mode Supabase actif, interroger Supabase
if (!chosenTrain && supabase) {
try {
if (gareIdParam) {
setResolvedGareId(gareIdParam);
} else if (gareDisplay) {
const { data: gdata } = await supabase.from('gares').select('id,nom').ilike('nom', `%${gareDisplay}%`);
if (gdata && gdata.length) {
const exactMatch = gdata.find(g => matchStationNameStrict(g.nom, gareDisplay));
const chosen = exactMatch || (gdata.length === 1 ? gdata[0] : null);
if (chosen) setResolvedGareId(chosen.id);
}
}
const { data: all, error } = await fetchAllRows(supabase.from('horaires').select('*'));
if (!error && Array.isArray(all) && all.length > 0) {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const nowHM = now.getHours() * 60 + now.getMinutes();
const candidates = [];
for (const h of all) {
if (!h) continue;
const circulatesToday = circulatesOn(h, now);
const circulatesTomorrow = circulatesOn(h, tomorrow);
const stops = getStops(h);
let stopMatch = null;
if (gareDisplay) {
const destName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString();
if (matchStationNameStrict(destName, gareDisplay)) {
// Destination finale -> terminus ici, pas un départ
continue;
}
const departName = (h.gare_depart_nom || h.gare_depart || '').toString();
if (matchStationNameStrict(departName, gareDisplay)) {
stopMatch = {
heure_depart: h.heure_depart || h.time || h.heure,
gare_nom: h.gare_depart_nom || h.gare_depart,
quai_reel: h.quai_reel || h.voie || h.quai
};
}
if (!stopMatch && Array.isArray(stops)) {
for (const s of stops) {
const sName = (typeof s === 'string' ? s : (s.gare_nom || s.nom || s.name || s.stop_name || '')).toString();
if (matchStationNameStrict(sName, gareDisplay)) {
stopMatch = s;
break;
}
}
}
} else {
stopMatch = {
heure_depart: h.heure_depart || h.time || h.heure,
gare_nom: h.gare_depart_nom || h.gare_depart,
quai_reel: h.quai_reel || h.voie || h.quai
};
}
if (!stopMatch) continue;
const rawTime = (stopMatch && typeof stopMatch === 'object' && (stopMatch.heure_depart || stopMatch.heure || stopMatch.time || stopMatch.heure_passage)) || h.heure_depart || h.time || h.heure;
const depHM = parseTimeHM(rawTime);
if (depHM == null) continue;
let platform = (stopMatch && (stopMatch.quai_reel || stopMatch.quai || stopMatch.platform || stopMatch.voie)) || h.quai_reel || h.voie || h.platform || h.quai || '';
const platClean = cleanPlatform(platform);
if (targetQuaiClean) {
if (!platClean || platClean !== targetQuaiClean) {
continue;
}
}
candidates.push({
horaire: h,
stop: stopMatch,
depHM,
rawTime,
platClean,
circulatesToday,
circulatesTomorrow
});
}
// Filtrage strict par quai si demandé
let filteredCandidates = candidates;
if (targetQuaiClean) {
filteredCandidates = candidates.filter(c => c.platClean === targetQuaiClean);
}
const todayCandidates = filteredCandidates.filter(c => c.circulatesToday && c.depHM >= nowHM - 1).sort((a, b) => a.depHM - b.depHM);
const tomorrowCandidates = filteredCandidates.filter(c => c.circulatesTomorrow && (c.depHM < nowHM - 1 || !c.circulatesToday)).sort((a, b) => a.depHM - b.depHM);
const chosen = todayCandidates.length > 0
? todayCandidates[0]
: (tomorrowCandidates.length > 0
? tomorrowCandidates[0]
: (filteredCandidates.length > 0 ? filteredCandidates.sort((a, b) => a.depHM - b.depHM)[0] : null));
if (chosen) {
const partnerCand = filteredCandidates.find(c => {
if (!c || c === chosen) return false;
const cNum = (c.horaire.numero_train || c.horaire.num || '').toString();
const chosenNum = (chosen.horaire.numero_train || chosen.horaire.num || '').toString();
return cNum !== chosenNum && c.depHM === chosen.depHM;
});
chosenTrain = {
...chosen,
partner: partnerCand ? partnerCand.horaire : null
};
}
}
} catch (err) {
console.error('Erreur chargement horaire Supabase quai:', err);
}
}
setSelectedHoraire(chosenTrain);
setLoadingHoraire(false);
}, [gareDisplay, gareParam, gareIdParam, quaiParam, parseTimeHM, normalizeName, cleanPlatform, circulatesOn, getStops]);
useEffect(() => {
loadNextForQuai();
const interval = setInterval(loadNextForQuai, 15000);
return () => clearInterval(interval);
}, [loadNextForQuai]);
// Données actives (réelles ou maquette Image 1 de secours)
const horaire = selectedHoraire?.horaire;
const rawDepartureTime = selectedHoraire ? (selectedHoraire.rawTime || horaire?.heure_depart || horaire?.time) : (gareParam ? null : mock.time);
const formattedTime = rawDepartureTime ? formatTimeNoSeconds(rawDepartureTime) : '';
const statusObj = horaire?._status;
const isSuppressed = statusObj?.label === 'supprimé' || horaire?.etat === 'supprime';
const isDelayed = (statusObj && typeof statusObj.delayMinutes === 'number' && statusObj.delayMinutes > 0) || (horaire?.retard_minutes > 0);
const delayMinutes = statusObj?.delayMinutes || horaire?.retard_minutes || 0;
let displayStatusText = "à l'heure";
if (isSuppressed) {
displayStatusText = 'supprimé';
} else if (isDelayed) {
displayStatusText = `retardé +${delayMinutes}min.`;
}
const destinationName = horaire ? (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '') : (gareParam ? '' : mock.destination);
const trainType = horaire ? (horaire.type_train || horaire.train_type || horaire.op || horaire.commercial_mode || 'TER') : (gareParam ? '' : mock.trainType);
const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : (gareParam ? '' : mock.trainNumber);
const operator = horaire ? (horaire.operator || horaire.op || '') : '';
// Résolution dynamique du logo couleur
const brand = getTrainBrand(trainType, operator, true);
// Calcul de la liste des arrêts pour le panneau droit
let stopsList = [];
if (horaire) {
const rawStops = getStops(horaire);
if (Array.isArray(rawStops) && rawStops.length > 0) {
stopsList = rawStops.map(s => (typeof s === 'string' ? s : (s.gare_nom || s.nom || s.name || s.stop_name || ''))).filter(Boolean);
}
// Filtrer les arrêts à partir de la gare courante
if (gareDisplay && stopsList.length > 0) {
const foundIdx = stopsList.findIndex(s => matchStationNameStrict(s, gareDisplay));
if (foundIdx >= 0 && foundIdx < stopsList.length - 1) {
stopsList = stopsList.slice(foundIdx + 1);
}
}
// Garantir que le terminus est présent en dernier
if (destinationName) {
const lastStop = stopsList.length > 0 ? stopsList[stopsList.length - 1] : '';
if (!matchStationNameStrict(lastStop, destinationName)) {
stopsList.push(destinationName);
}
}
}
// Fallback propre
if (!stopsList.length) {
if (horaire && destinationName) {
stopsList = [destinationName];
} else if (!gareParam) {
stopsList = mock.stops;
}
}
// 1. Détection "Départ en cours" (2 minutes avant l'heure de départ réelle)
let isDepartEnCours = isForceDepartEnCours;
if (!isDepartEnCours && selectedHoraire && rawDepartureTime) {
const depHM = parseTimeHM(rawDepartureTime);
if (depHM != null) {
const nowSec = currentTime.getHours() * 3600 + currentTime.getMinutes() * 60 + currentTime.getSeconds();
const depSec = (depHM + delayMinutes) * 60;
let diffSec = depSec - nowSec;
if (diffSec < -43200) diffSec += 86400;
if (diffSec <= 120 && diffSec >= 0) {
isDepartEnCours = true;
}
}
}
// AFFICHAGE 1 : Page Vidéoprotection (via query)
if (isVideoprotection) {
return <GareVideoprotection />;
}
// AFFICHAGE 2 : Départ en cours (T - 2 min)
if (isDepartEnCours) {
return <DepartEnCoursClassique />;
}
// AFFICHAGE 3 : Alternance si aucun train n'est attribué à ce quai
if (!selectedHoraire && gareParam) {
if (idlePhase === 0) {
return <AttentifsEnsemble />;
}
if (idlePhase === 1) {
return <ClassiquesDeparts defilement={true} />;
}
if (idlePhase === 2) {
return <ClassiquesInformations />;
}
}
const partner = selectedHoraire?.partner;
const destinationName1 = destinationName;
const destinationName2 = partner ? (partner.gare_arrivee_nom || partner.gare_arrivee || partner.destination || partner.dest || '') : (horaire?.second_destination || horaire?.dest2 || horaire?.destination2 || (horaire?.tranches && horaire?.tranches[1]?.destination) || '');
const trainType1 = trainType;
const trainType2 = partner ? (partner.type_train || partner.train_type || partner.type || partner.op || 'TGV') : (horaire?.type_train2 || horaire?.train_type2 || (destinationName2 ? trainType : ''));
const trainNumber1 = trainNumber;
const trainNumber2 = partner ? (partner.numero_train || partner.numero || partner.num || '') : (horaire?.numero_train2 || horaire?.num2 || (horaire?.tranches && horaire?.tranches[1]?.num) || '');
const isDifferentDest = Boolean(destinationName1 && destinationName2 && !matchStationNameStrict(destinationName1, destinationName2));
// Récupération des arrêts de la deuxième tranche
let stopsList2 = [];
if (partner) {
const rawStops2 = getStops(partner);
if (Array.isArray(rawStops2) && rawStops2.length > 0) {
stopsList2 = rawStops2.map(s => (typeof s === 'string' ? s : (s.gare_nom || s.nom || s.name || s.stop_name || ''))).filter(Boolean);
}
if (gareDisplay && stopsList2.length > 0) {
const fIdx = stopsList2.findIndex(s => matchStationNameStrict(s, gareDisplay));
if (fIdx >= 0 && fIdx < stopsList2.length - 1) {
stopsList2 = stopsList2.slice(fIdx + 1);
}
}
if (destinationName2 && !matchStationNameStrict(stopsList2[stopsList2.length - 1] || '', destinationName2)) {
stopsList2.push(destinationName2);
}
}
// Calcul du tronc commun et des branches de séparation (Fourche Image 2)
let commonStops = [];
let branch1Stops = [];
let branch2Stops = [];
if (isDifferentDest && stopsList.length > 0 && stopsList2.length > 0) {
let divIdx = 0;
const minLen = Math.min(stopsList.length, stopsList2.length);
while (divIdx < minLen && matchStationNameStrict(stopsList[divIdx], stopsList2[divIdx])) {
commonStops.push(stopsList[divIdx]);
divIdx++;
}
branch1Stops = stopsList.slice(divIdx);
branch2Stops = stopsList2.slice(divIdx);
}
const isTgv = Boolean(
(trainType1 || '').toUpperCase().includes('TGV') ||
(trainType2 || '').toUpperCase().includes('TGV') ||
(operator || '').toUpperCase().includes('TGV') ||
(brand && (brand.label || '').toUpperCase().includes('TGV')) ||
(horaire && ((horaire.type_train || horaire.type || '').toUpperCase().includes('TGV') || (horaire.op || '').toUpperCase().includes('TGV')))
);
return (
<div className="aff-classique-quai-dep__container">
{/* PANNEAU GAUCHE : BLANC (Conforme Image 1 & Image 2) */}
<div className={`aff-classique-quai-dep__left ${isTgv ? 'aff-classique-quai-dep__left--tgv' : ''}`}>
{/* Logo Couleur Dynamique */}
<div className="aff-classique-quai-dep__brand">
<img src={brand.logo} alt={brand.label} className="aff-classique-quai-dep__brand-logo" />
{brand.showVoyageursText && (
<div className="aff-classique-quai-dep__sncf-voyageurs">VOYAGEURS</div>
)}
</div>
{/* Heure et Statut */}
<div className="aff-classique-quai-dep__time-row">
{(isSuppressed || isDelayed) ? (
showStatusAlt ? (
<span className={`aff-classique-quai-dep__status-alt ${isSuppressed ? 'aff-classique-quai-dep__status--suppressed' : 'aff-classique-quai-dep__status--delayed'}`}>
{displayStatusText}
</span>
) : (
<span className="aff-classique-quai-dep__time">
{formattedTime || '--:--'}
</span>
)
) : (
<>
<span className="aff-classique-quai-dep__time">
{formattedTime || '--:--'}
</span>
<span className="aff-classique-quai-dep__status">
{displayStatusText}
</span>
</>
)}
</div>
{/* Destination Principale */}
<div className={`aff-classique-quai-dep__destination ${isDifferentDest ? 'aff-classique-quai-dep__destination--multi' : ''}`}>
{destinationName1 || 'Aucun départ prévu'}
</div>
{/* Type de Train & Numéro 1 */}
<div className="aff-classique-quai-dep__train-info">
{trainType1 && <span className="aff-classique-quai-dep__train-type">{trainType1}</span>}
{trainNumber1 && <span className="aff-classique-quai-dep__train-number">{trainNumber1}</span>}
</div>
{/* Destination 2 si multi-tranche / destinations différentes (ex: Nice-Ville ou Bruxelles Midi) */}
{isDifferentDest && (
<>
<div className="aff-classique-quai-dep__destination aff-classique-quai-dep__destination--multi" style={{ marginTop: '16px' }}>
{destinationName2}
</div>
<div className="aff-classique-quai-dep__train-info">
{trainType2 && <span className="aff-classique-quai-dep__train-type">{trainType2}</span>}
{trainNumber2 && <span className="aff-classique-quai-dep__train-number">{trainNumber2}</span>}
</div>
</>
)}
{/* Si même destination mais 2 numéros couplés (ex: 2 TGV départ 16h17 vers Paris) */}
{!isDifferentDest && trainNumber2 && trainNumber2 !== trainNumber1 && (
<div className="aff-classique-quai-dep__train-info" style={{ marginTop: '6px' }}>
{trainType2 && <span className="aff-classique-quai-dep__train-type">{trainType2}</span>}
<span className="aff-classique-quai-dep__train-number">{trainNumber2}</span>
</div>
)}
{/* Pictogrammes de services pour TGV (PMR, Vélo, Bar) */}
{isTgv && <TgvServiceIcons />}
{/* Filigrane Vertical "départ" géant (orienté bas vers haut) */}
<div className="aff-classique-quai-dep__watermark" aria-hidden="true">
<span>départ</span>
</div>
</div>
{/* PANNEAU DROIT : BLEU NUIT AVEC DESSERTE (Conforme Image 1 & Fourche Image 2) */}
<div className={`aff-classique-quai-dep__right ${isTgv ? 'aff-classique-quai-dep__right--tgv' : ''}`}>
<div className="aff-classique-quai-dep__stops-wrapper">
{isDifferentDest && commonStops.length > 0 ? (
/* AFFICHAGE FOURCHE / BIFURCATION (Conforme Image 2) */
<div className="aff-classique-quai-dep__fork-container">
{/* Tronc commun */}
<ul className="aff-classique-quai-dep__stops-list">
{commonStops.map((stopName, idx) => (
<li key={idx} className="aff-classique-quai-dep__stop-item">
<span className="aff-classique-quai-dep__bullet" />
<span className="aff-classique-quai-dep__stop-name">{stopName}</span>
</li>
))}
</ul>
{/* Ligne d'embranchement avec courbe */}
<div className="aff-classique-quai-dep__fork-split-row">
<div className="aff-classique-quai-dep__fork-curve-rail" />
<div className="aff-classique-quai-dep__fork-branch-right">
<div className="aff-classique-quai-dep__fork-coaches-label">voitures 11 à 18</div>
<div className="aff-classique-quai-dep__fork-terminus-box">
<span className="aff-classique-quai-dep__bullet" style={{ position: 'static', margin: 0 }} />
<span className="aff-classique-quai-dep__fork-terminus-name">
{branch2Stops[branch2Stops.length - 1] || destinationName2}
</span>
</div>
</div>
</div>
{/* Branche principale continuant vers terminus 1 */}
{branch1Stops.length > 0 && (
<ul className="aff-classique-quai-dep__stops-list" style={{ marginTop: '8px' }}>
{branch1Stops.map((stopName, idx) => {
const isLast = idx === branch1Stops.length - 1;
return (
<li
key={idx}
className={`aff-classique-quai-dep__stop-item ${isLast ? 'aff-classique-quai-dep__stop-item--dest' : ''}`}
>
<span className="aff-classique-quai-dep__bullet" />
<span className="aff-classique-quai-dep__stop-name">{stopName}</span>
</li>
);
})}
</ul>
)}
</div>
) : (
/* AFFICHAGE STANDARD DESSERTE VERTICALE (Conforme Image 1) */
stopsList.length > 0 ? (
<ul className="aff-classique-quai-dep__stops-list">
{stopsList.map((stopName, idx) => {
const isLast = idx === stopsList.length - 1;
return (
<li
key={idx}
className={`aff-classique-quai-dep__stop-item ${isLast ? 'aff-classique-quai-dep__stop-item--dest' : ''}`}
>
<span className="aff-classique-quai-dep__bullet" />
<span className="aff-classique-quai-dep__stop-name">
{stopName}
</span>
</li>
);
})}
</ul>
) : (
<div style={{ color: '#ffffff', fontSize: '38px', fontWeight: 600, padding: '40px' }}>
Veuillez consulter les écrans du hall de la gare.
</div>
)
)}
</div>
</div>
{/* BANDEAU BAS : TGV COMPOSITION OU BANDEAU STANDARD */}
{isTgv ? (
<TgvCompositionQuai
platform={quaiParam || horaire?.quai || horaire?.quai_reel || horaire?.voie || horaire?._displayPlatform}
destination={destinationName1}
destination2={isDifferentDest ? destinationName2 : destinationName1}
composition={horaire?.composition}
marqueeMessage={horaire?._status?.causeDetails || horaire?.cause_retard || (isSuppressed ? `TRAIN SUPPRIMÉ - MERCI DE VOTRE COMPRÉHENSION` : (isDelayed ? `TRAIN RETARDÉ DE ${delayMinutes} MIN - MERCI DE VOTRE COMPRÉHENSION` : ''))}
/>
) : (
<div className="aff-classique-quai-dep__footer">
<div className="aff-classique-quai-dep__footer-track">
{quaiParam ? `Voie ${cleanPlatform(quaiParam).toUpperCase()}` : (horaire?.quai || horaire?.voie || horaire?._displayPlatform ? `Voie ${cleanPlatform(horaire.quai || horaire.voie || horaire._displayPlatform).toUpperCase()}` : '')}
</div>
<div className="aff-classique-quai-dep__footer-clock">
<DigitalClock />
</div>
</div>
)}
</div>
);
}