feat(classiques): support extended lines (1-3), bottom clock/info ticker (time=true), and dynamic SNCF Open Data logo resolution

This commit is contained in:
mathis committed 2026-08-30 23:23:56 +02:00
1 parent 427693c492
commit 5e9d770449
6 files changed
+981 -115

No files matched your search

@@ -7,6 +7,7 @@ import useAutoAnnonce from '../../../hooks/useAutoAnnonce';
import { fetchAllRows } from '../../../lib/fetchAllRows';
import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi';
import { getTrainLogoAndMeta } from '../../../utils/trainLogoHelper';
// Helper utilitaire : format HHhMM sans secondes (ex: 06h44)
const formatTimeNoSeconds = (v) => {
@@ -66,53 +67,61 @@ const formatStationName = (name) => {
return str;
};
const getTrainMeta = (horaire) => {
const opRaw = String(horaire.type_train || horaire.op || horaire.mode || '').trim();
const numTrain = String(horaire.numero_train || horaire.numero || horaire.number || horaire.trip_short_name || '').trim();
const opUpper = opRaw.toUpperCase();
let logoSrc = '/type/logo-sncf.svg';
let typeLabel = 'TER';
if (opUpper.includes('INOUI') || opUpper.includes('TGV')) {
logoSrc = '/type/logo-inoui.svg';
typeLabel = 'TGV inOUI';
} else if (opUpper.includes('OUIGO')) {
logoSrc = '/type/logo-ouigo.svg';
typeLabel = 'OUIGO';
} else if (opUpper.includes('MOBIGO')) {
logoSrc = '/type/logo-sncf.svg';
typeLabel = 'MOBIGO';
} else if (opUpper.includes('ICE')) {
logoSrc = '/type/logo-ice.svg';
typeLabel = 'ICE';
} else if (opUpper.includes('LYRIA')) {
logoSrc = '/type/logo-lyria.svg';
typeLabel = 'TGV Lyria';
} else if (opUpper.includes('INTERCIT') || opUpper.includes('IC')) {
logoSrc = '/type/logo-sncf.svg';
typeLabel = 'INTERCITÉS';
} else if (opUpper.includes('CAR') || opUpper.includes('AUTOCAR') || opUpper.includes('BUS')) {
logoSrc = '/type/logo-sncf.svg';
typeLabel = 'AUTOCAR';
} else if (opRaw) {
logoSrc = '/type/logo-sncf.svg';
typeLabel = opRaw;
}
// Si c'est un numéro typique MOBIGO en région BFC (séries 89xxxx, 84xxxx)
if (typeLabel === 'TER' && (numTrain.startsWith('89') || numTrain.startsWith('84'))) {
typeLabel = 'MOBIGO';
}
return { logoSrc, typeLabel, numTrain };
};
const formatPlatform = (p) => {
if (!p) return '';
return String(p).toUpperCase().replace(/VOIE|QUAI|\s+/gi, '').trim();
};
const formatStopsForDeparts = (horaire, currentStationName, normalizeFn) => {
const stops = (horaire && (horaire.stops || horaire.gares_desservies)) || [];
if (!Array.isArray(stops) || stops.length === 0) {
const dest = formatStationName((horaire && (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination)) || '');
return dest ? `• ${dest}` : '';
}
const curNorm = normalizeFn ? normalizeFn(currentStationName || '') : String(currentStationName || '').toLowerCase().trim();
let startIdx = 0;
if (curNorm) {
const foundIdx = stops.findIndex(s => {
const sName = (s.nom || s.gare_nom || s.name || s.stop_name || '').toString();
const norm = normalizeFn ? normalizeFn(sName) : sName.toLowerCase().trim();
return norm === curNorm;
});
if (foundIdx >= 0) {
startIdx = foundIdx + 1;
}
}
const remainingStops = stops.slice(startIdx);
if (remainingStops.length === 0) {
const dest = formatStationName((horaire && (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination)) || '');
return dest ? `• ${dest}` : '';
}
const stopNames = remainingStops.map(s => formatStationName(s.nom || s.gare_nom || s.name || s.stop_name || '')).filter(Boolean);
return stopNames.map(n => `• ${n}`).join(' ');
};
function BottomClock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return (
<div className="aff-classiques-departs__bottom-clock" aria-hidden>
<span>{hh}</span>
<span>{mm}</span>
<span>{ss}</span>
</div>
);
}
function Clock(){
const [now, setNow] = useState(new Date());
@@ -1206,12 +1215,27 @@ export default function ClassiquesDeparts(){
const renderRow = (h, idx) => {
if (h._isEmpty) {
return <li className="aff-classiques-departs__row aff-classiques-departs__row--empty" key={`empty-${idx}`} style={{ opacity: 0 }}></li>;
const isExtended = Boolean(h && h._isExtended);
const isOdd = ((h && typeof h._slotIndex === 'number' ? h._slotIndex : idx) % 2 === 0);
if (h && h._isEmpty) {
if (isExtended) {
return (
<li
className={`aff-classiques-departs__row aff-classiques-departs__row--extended ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`}
key={`empty-${idx}`}
style={{ opacity: 0 }}
>
<div className="aff-classiques-departs__extended-top"></div>
<div className="aff-classiques-departs__extended-bottom"></div>
</li>
);
}
return <li className={`aff-classiques-departs__row ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`} key={`empty-${idx}`} style={{ opacity: 0 }}></li>;
}
const horaire = h || {};
const { logoSrc, typeLabel, numTrain } = getTrainMeta(horaire);
const { logoSrc, typeLabel, numTrain } = getTrainLogoAndMeta(horaire);
const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.departure_time || horaire.time || horaire.heure || '');
const destination = horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '';
const formattedDestination = formatStationName(destination);
@@ -1231,20 +1255,91 @@ export default function ClassiquesDeparts(){
const stationName = gareDisplay || horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null;
const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));
const isSuppressed = status && status.label === 'supprimé';
const isDelayed = status && status.label && String(status.label).startsWith('retardé');
const showPlatform = !isSuppressed && Boolean(cleanPlatform);
const stopsText = isExtended ? formatStopsForDeparts(horaire, gareDisplay || gareParam, normalizeName) : '';
if (isExtended) {
return (
<li
className={`aff-classiques-departs__row aff-classiques-departs__row--extended ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`}
key={idx}
>
{/* Ligne haute (108px) : infos complètes */}
<div className="aff-classiques-departs__extended-top">
<div className="aff-classiques-departs__col-logo">
<img src={logoSrc} alt={typeLabel} className="aff-classiques-departs__op-logo" />
</div>
<div className="aff-classiques-departs__col-meta">
{showStatus ? (
isSuppressed ? (
<div className="aff-classiques-departs__status-suppressed">supprimé</div>
) : isDelayed ? (
<div className="aff-classiques-departs__status-delayed">retard {status.delayMinutes} min.</div>
) : (
<div className="aff-classiques-departs__status-ontime">à l'heure</div>
)
) : (
<>
<div className="aff-classiques-departs__train-type">{typeLabel}</div>
<div className="aff-classiques-departs__train-number">{numTrain}</div>
</>
)}
</div>
<div className="aff-classiques-departs__col-time">
<div className="aff-classiques-departs__time">{time}</div>
</div>
<div className="aff-classiques-departs__col-destination">
<MarqueeText text={formattedDestination} className="aff-classiques-departs__destination" />
</div>
<div className="aff-classiques-departs__col-platform">
{showPlatform ? (
<div className="aff-classiques-departs__platform-box">
<div className="aff-classiques-departs__platform-text">{cleanPlatform}</div>
</div>
) : null}
</div>
</div>
{/* Ligne basse (108px) : gares desservies défilantes */}
<div className="aff-classiques-departs__extended-bottom">
<div className="aff-classiques-departs__sub-stops">
<MarqueeText text={stopsText} className="aff-classiques-departs__sub-stops-text" />
</div>
</div>
</li>
);
}
return (
<li className="aff-classiques-departs__row" key={idx}>
<li className={`aff-classiques-departs__row ${isOdd ? 'aff-classiques-departs__row--odd' : 'aff-classiques-departs__row--even'}`} key={idx}>
{/* COL 1 : Logo opérateur */}
<div className="aff-classiques-departs__col-logo">
<img src={logoSrc} alt={typeLabel} className="aff-classiques-departs__op-logo" />
</div>
{/* COL 2 : Type + Numéro */}
{/* COL 2 : Type + Numéro OU Statut */}
<div className="aff-classiques-departs__col-meta">
<div className="aff-classiques-departs__train-type">{typeLabel}</div>
<div className="aff-classiques-departs__train-number">{numTrain}</div>
{showStatus ? (
isSuppressed ? (
<div className="aff-classiques-departs__status-suppressed">supprimé</div>
) : isDelayed ? (
<div className="aff-classiques-departs__status-delayed">retard {status.delayMinutes} min.</div>
) : (
<div className="aff-classiques-departs__status-ontime">à l'heure</div>
)
) : (
<>
<div className="aff-classiques-departs__train-type">{typeLabel}</div>
<div className="aff-classiques-departs__train-number">{numTrain}</div>
</>
)}
</div>
{/* COL 3 : Heure (grande, jaune) */}
@@ -1270,11 +1365,44 @@ export default function ClassiquesDeparts(){
);
};
const displayList = [...horaires.slice(0, 10)];
while (displayList.length < 10) {
displayList.push({ _isEmpty: true });
// Gestion des paramètres extended et time
const queryParams = new URLSearchParams(window.location.search || '');
const extendedParam = parseInt(queryParams.get('extended') || queryParams.get('extendedLines') || '0', 10);
const isTimeEnabled = queryParams.get('time') === 'true' || queryParams.get('time') === '1';
const maxSlots = isTimeEnabled ? 9 : 10;
const extendedCount = Math.min(Math.max(0, extendedParam), 3);
const displayRows = [];
let trainIdx = 0;
let usedSlots = 0;
for (let i = 0; i < extendedCount && usedSlots + 2 <= maxSlots; i++) {
const train = (horaires && horaires[trainIdx]) || { _isEmpty: true };
displayRows.push({
...train,
_isExtended: true,
_slotIndex: usedSlots
});
usedSlots += 2;
trainIdx++;
}
while (usedSlots < maxSlots) {
const train = (horaires && horaires[trainIdx]) || { _isEmpty: true };
displayRows.push({
...train,
_isExtended: false,
_slotIndex: usedSlots
});
usedSlots += 1;
trainIdx++;
}
const infoMessage = (perturbations && perturbations.length > 0)
? perturbations.map(p => p.titre ? `${p.titre} : ${p.message || p.description || ''}` : p.message || '').join(' • ')
: "Trafic normal sur l'ensemble de la ligne • SNCF Voyageurs vous souhaite un agréable voyage.";
return (
<div className="aff-classiques-departs__container">
@@ -1288,7 +1416,7 @@ export default function ClassiquesDeparts(){
<div className="aff-classiques-departs__scroll-container">
{horaires.length === 0 ? (
<ul className="aff-classiques-departs__list">
{displayList.map((h, idx) => renderRow(h, idx))}
{displayRows.map((h, idx) => renderRow(h, idx))}
</ul>
) : (
<ul
@@ -1299,9 +1427,17 @@ export default function ClassiquesDeparts(){
)}
</div>
) : (
/* ── MODE NORMAL : 10 lignes statiques ── */
/* ── MODE NORMAL : 9 ou 10 lignes ── */
<ul className="aff-classiques-departs__list">
{displayList.map((h, idx) => renderRow(h, idx))}
{displayRows.map((h, idx) => renderRow(h, idx))}
{isTimeEnabled && (
<li className="aff-classiques-departs__row aff-classiques-departs__row--info-bottom">
<div className="aff-classiques-departs__info-bar">
<MarqueeText text={infoMessage} className="aff-classiques-departs__info-bar-text" />
</div>
<BottomClock />
</li>
)}
</ul>
)}