V2.0.0 : VOICI LES MODIFICATIONS MAJEURS APPRTÉES DANS CETTE VERSION :

-> REFONTE DU DESIGN DES AFFICHEURS CLASSIQUES (GÉNÉRAL + QUAIS)
-> IMPLÉMENTATION DU DESIGN AFL : GÉNÉRAL ET QUAIS

-> CHOIX DE LA SÉLECTION DES TYPES D'AFFICHEURS (GÉNÉRAL / QUAIS) UNE FOIS LE TYPE D'AFFICHEUR SÉLECTIONNÉS

EN COURS DE DÉVELOPPEMENT :

-> IMPLÉMENTATION DES PERTURBATION DU LANDEMAIN POUR LES HORAIRES DU LANDEMAIN AFFICHÉS AVANT MINUIT.
-> IMPLÉMENTATION DES AFFICHEURS D'INFORMATIONS / INFOS TRAVAUX, etc... (AFFICHEURS AFL)
This commit is contained in:
Ferrovia committed 2026-06-14 22:02:08 +02:00
1 parent f0d9ed9ee7
commit f06ebbaf48
43 files changed
+10519 -3813

No files matched your search

+881
View File
@@ -0,0 +1,881 @@
import React, { useState, useEffect, useCallback } from 'react';
import supabase from '../../../lib/supabase';
import './AFLDepartsQuais.css';
// Helper utilitaire : format HH:MM sans secondes (ex: 15:57)
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}:${mm}`;
}
const parts = String(v).split(':');
if (parts.length >= 2) {
return `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`;
}
return v;
};
const AFLDepartsQuais = () => {
const [currentTime, setCurrentTime] = useState(new Date());
const [nextDeparture, setNextDeparture] = useState(null);
const [loading, setLoading] = useState(true);
// Parse URL manually since we are not in a React Router context
const searchParams = new URLSearchParams(window.location.search);
const gareParam = searchParams.get('gare') || '';
const quaiParam = searchParams.get('quai') || searchParams.get('voie') || '';
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;
// Real-time clock update
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// --- Helpers pour perturbations, travaux, etc (portés de AFLDeparts.js) ---
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) {}
}
}
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 parseTimeHM = useCallback((s) => {
if (!s) return null;
if (typeof s === 'number') return s;
const m = String(s).match(/(\d{1,2})[h:](\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 buildPerturbMap = useCallback((list, allHorairesList) => {
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(allHorairesList) && allHorairesList.length) {
try {
const byH = Object.keys(idx.byHoraire || {});
if (byH && byH.length) {
for (const hid of byH) {
const h = allHorairesList.find(x => String(x.id) === 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;
}, [safeParseArrets, normalizeName]);
const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => {
const map = idx || buildPerturbMap();
const allPerturbs = [];
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 (map) {
const addedIds = new Set();
const addP = (p) => {
if (p && !addedIds.has(p.id)) {
addedIds.add(p.id);
allPerturbs.push(p);
}
};
if (hId != null && map.byHoraire && map.byHoraire[String(hId)]) {
map.byHoraire[String(hId)].forEach(addP);
}
if (number != null && map.byNumber && map.byNumber[String(number)]) {
map.byNumber[String(number)].forEach(addP);
}
if (stationId != null && map.byStopId && map.byStopId[String(stationId)]) {
map.byStopId[String(stationId)].forEach(x => addP(x.perturbation));
}
if (stationName && map.byStopName) {
const key = normalizeName(stationName);
if (map.byStopName[key]) map.byStopName[key].forEach(x => addP(x.perturbation));
for (const existingKey of Object.keys(map.byStopName)) {
if (existingKey.includes(key) || key.includes(existingKey)) {
map.byStopName[existingKey].forEach(x => addP(x.perturbation));
}
}
}
const trainStops = getStops(horaire);
if (Array.isArray(trainStops)) {
trainStops.forEach(s => {
const sId = s.gare_id || s.gareId || s.id || s.code;
const sName = s.gare_nom || s.nom || s.name || s.stop_name;
if (sId != null && map.byStopId && map.byStopId[String(sId)]) {
map.byStopId[String(sId)].forEach(x => addP(x.perturbation));
}
if (sName && map.byStopName) {
const skey = normalizeName(sName);
if (map.byStopName[skey]) map.byStopName[skey].forEach(x => addP(x.perturbation));
}
});
}
if (map.global) {
map.global.forEach(addP);
}
}
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;
let isSuppressed = false;
for (const p of allPerturbs) {
try {
if (horaireYmd) {
const pDate = toYMD(p.date || p.date_service);
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 && !joursSet.has(horaireYmd)) continue;
} catch (e) {}
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 parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };
const arrets = parsedObj.arrets || [];
const trainStops = getStops(horaire);
let trainStopMatch = false;
const matchesTarget = (a) => {
if (!stationName && stationId == null) return false;
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;
if (stationId != null && String(aId) === String(stationId)) return true;
if (stationName && aName) {
const na = normalizeName(aName);
const ns = normalizeName(stationName);
return na === ns || na.includes(ns) || ns.includes(na);
}
return false;
};
for (const a of arrets) {
const aId = a.gare_id || a.gareId || a.id || a.uic || a.code;
const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
const na = normalizeName(aName || '');
const isTarget = matchesTarget(a);
let isTrainStop = false;
if (trainStops && Array.isArray(trainStops)) {
isTrainStop = trainStops.some(s => {
const sId = s.gare_id || s.gareId || s.id || s.code;
const sName = s.gare_nom || s.nom || s.name || s.stop_name;
if (sId != null && aId != null && String(sId) === String(aId)) return true;
if (sName && aName) {
const ns = normalizeName(sName);
return ns === na || ns.includes(na) || na.includes(ns);
}
return false;
});
}
const originName = normalizeName(horaire.gare_depart_nom || horaire.gare_depart || '');
const destName = normalizeName(horaire.gare_arrivee_nom || horaire.gare_arrivee || '');
if (originName === na || destName === na) isTrainStop = true;
if (isTrainStop) trainStopMatch = true;
if (isTarget) {
const t = (a.type_perturbation || a.type || '').toString().toLowerCase();
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) {
isSuppressed = true;
}
}
}
const isGlobal = (arrets.length === 0) && !ref && !numberRef;
const applies = appliesByRef || appliesByNumber || trainStopMatch || isGlobal;
if (applies) {
const t = (p.type_perturbation || p.type || '').toString().toLowerCase();
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) {
isSuppressed = true;
}
for (const a of arrets) {
const tType = (a.type_perturbation || a.type || '').toString().toLowerCase();
if (tType.includes('retard') || tType.includes('delay') || /retard/.test(tType)) {
const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;
if (maxDelay == null || d > maxDelay) maxDelay = d;
}
}
if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {
const d = parseDelayMinutesFromArret(p) || 0;
if (maxDelay == null || d > maxDelay) maxDelay = d;
}
}
}
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };
return { label: "à l'heure", detail: '', delayMinutes: null };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
const applyPerturbationsToHoraires = useCallback((list, perturbList, displayStationId, displayStationName, serviceDate, map) => {
const targetStationId = displayStationId || null;
const targetStationName = displayStationName || 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 });
}
const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate);
let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || '');
horaire._stopsStatus = stopsStatus;
horaire._status = displayStatus;
horaire._displayTime = displayTime;
return horaire;
});
}, [computeStatusForHoraireAtStop, getStops]);
const fetchData = useCallback(async () => {
try {
const [{ data: horairesData }, { data: subData }, { data: perturbData }, { data: travauxData }] = await Promise.all([
supabase.from('horaires').select('*'),
supabase.from('travaux_substitutions').select('*'),
supabase.from('perturbations').select('*'),
supabase.from('travaux').select('*')
]);
let parsedHoraires = (horairesData || []).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 || {};
});
const substitutions = subData || [];
const travauxList = travauxData || [];
// Link substitutions
parsedHoraires = parsedHoraires.map(h => {
const subs = substitutions.filter(s => String(s.horaire_id) === String(h.id));
if (subs.length > 0) {
return { ...h, _substitutions: subs };
}
return h;
});
// Normaliser date
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 = parsedHoraires.map(h => ({ ...h, _date: normalizeDate(h) }));
// Time parsing & enrichment
const now = new Date();
const todayHM = now.getHours() * 60 + now.getMinutes();
const enrich = withDates.map(h => {
let raw = h.heure_depart || h.time || h.heure || '';
const stopsListForH = getStops(h);
if (gareDisplay && Array.isArray(stopsListForH)) {
const stop = stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay));
if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure;
}
const depHM = parseTimeHM(raw);
const displayTime = formatTimeNoSeconds(raw);
return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime };
});
// Jours de circulation
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 isPublicHoliday = (d) => {
const y = d.getFullYear();
const m = d.getMonth() + 1;
const day = d.getDate();
const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const holidays = [
`${y}-01-01`, `${y}-05-01`, `${y}-05-08`, `${y}-07-14`, `${y}-08-15`, `${y}-11-01`, `${y}-11-11`, `${y}-12-25`
];
if (y === 2026) {
holidays.push('2026-04-06');
holidays.push('2026-05-14');
holidays.push('2026-05-25');
}
return holidays.includes(s);
};
const parseCirculationFlag = (val, defaultVal) => {
if (val == null) return defaultVal;
if (typeof val === 'boolean') return val;
const s = String(val).trim().toLowerCase();
if (['true', '1', 't', 'y', 'yes', 'oui'].includes(s)) return true;
if (['false', '0', 'f', 'n', 'no', 'non'].includes(s)) return false;
return defaultVal;
};
const circulatesOn = (h, d) => {
if (isPublicHoliday(d)) {
return parseCirculationFlag(h['circule_jours_feries'], false);
}
const col = weekdayNameForColumn(d);
if (!col) return true;
return parseCirculationFlag(h[col], true);
};
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.start_date);
const df = parseDateOnly(t.date_fin || 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.start_time);
const endHM = parseTimeHM(t.heure_fin || t.end_time);
if (startHM == null || endHM == null) return true;
if (typeof h._depHM === 'number') return h._depHM >= startHM && h._depHM <= endHM;
return false;
};
const isSubstitution = (h) => {
if (!h) return false;
return substitutions.some(ts => String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero));
};
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;
const hLine = h.ligne_id || h.ligne || h.line_id || h.line;
if (typeof tLine !== 'undefined' && tLine !== null) {
if (typeof hLine === 'undefined' || hLine === null || String(tLine) !== String(hLine)) {
continue;
}
}
const ref = t.horaire_id || t.horaire || t.numero_train || t.numero;
if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true;
const substForThisTravail = substitutions.some(ts => String(ts.travaux_id) === String(t.id) && (String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero)));
if (substForThisTravail) 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 };
});
// Filter by station: keep departures from this station or stops at this station (excluding terminus)
let stationFiltered = enriched;
if (gareDisplay) {
const normalized = normalizeName(gareDisplay);
stationFiltered = enriched.filter(h => {
const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());
if (destName === normalized) return false;
const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString());
if (departName === normalized) return true;
const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) {
let maxIdx = null;
for (const st of stopsList) {
if (typeof st.index !== 'undefined' && st.index !== null) {
const n = Number(st.index);
if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);
}
}
for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || '').toString());
if (!stopName) continue;
const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
if (isTerminus) continue;
if (stopName === normalized) return true;
}
}
return false;
});
}
// Candidate filters for Today and Tomorrow (Departure platform logic)
const todayCandidates = stationFiltered.filter(h => {
if (isSubstitution(h)) return Boolean(h._linkedToTravaux && h._travauxAppliesToday);
if (h._date && h._date !== todayStr) return false;
if (!circulatesOn(h, today)) return false;
if (typeof h._depHM !== 'number') return false;
// Keep departures that occur in the future or up to 1 minute ago (departure window)
if (h._depHM < todayHM - 1) return false;
if (h._travauxAppliesToday) return false;
return true;
});
const tomorrowCandidates = stationFiltered.filter(h => {
if (isSubstitution(h)) return Boolean(h._linkedToTravaux && h._travauxAppliesTomorrow);
if (h._date && h._date !== tomorrowStr) return false;
if (!circulatesOn(h, tomorrow)) return false;
if (h._travauxAppliesTomorrow) return false;
return true;
});
let finalList = [];
let serviceDateStr = '';
if (todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a, b) => a._depHM - b._depHM);
serviceDateStr = todayStr;
} else {
finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM);
serviceDateStr = tomorrowStr;
}
// Apply perturbations (delays, cancellations)
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);
const db = toYMD(p.date_debut || p.start_date);
const df = toYMD(p.date_fin || p.end_date);
if (pDate && pDate !== serviceDateStr) return false;
if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false;
const joursSet = getPerturbationJours(p);
if (joursSet && !joursSet.has(serviceDateStr)) return false;
return true;
} catch (e) { return true; }
});
const map = buildPerturbMap(filteredPerturb, parsedHoraires);
const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb, null, gareDisplay, serviceDateStr, map);
// Now filter by platform specific to this quai
const candidatesOnPlatform = annotated.filter(h => {
const stopsList = getStops(h);
let stopForStation = null;
if (gareDisplay && Array.isArray(stopsList)) {
stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay));
}
const platform = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || h.quai_reel || h.voie || h.quai || '').toString().trim();
return quaiParam && platform.toLowerCase() === quaiParam.toLowerCase();
});
if (candidatesOnPlatform.length > 0) {
setNextDeparture(candidatesOnPlatform[0]);
} else {
setNextDeparture(null);
}
setLoading(false);
} catch (err) {
console.error(err);
setLoading(false);
}
}, [applyPerturbationsToHoraires, buildPerturbMap, gareDisplay, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 15000);
return () => clearInterval(interval);
}, [fetchData]);
if (loading) return <div className="afl-quais-departs__container" style={{background: '#042054'}}></div>;
if (!nextDeparture) return (
<div className="afl-quais-departs__container">
<div className="afl-quais-departs__header">
<div className="afl-quais-departs__header-left">
<svg viewBox="0 0 44 24" width="6.5vh" height="4.5vh" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5vw' }}>
<path d="M4 6l6 6-6 6M12 6l6 6-6 6" stroke="white" strokeWidth="2.5" opacity="0.75" />
<path d="M22 17h16c1.5 0 2.5-1 2.5-2.5v-5C40.5 8 39.5 7 38 7H22c-1.5 0-2.5 1-2.5 2.5v5c0 1.5 1 2.5 2.5 2.5z" fill="white" stroke="none" />
<rect x="23" y="9" width="3" height="4" fill="#083c84" stroke="none" rx="0.5" />
<rect x="28" y="9" width="3" height="4" fill="#083c84" stroke="none" rx="0.5" />
<rect x="33" y="9" width="4" height="4" fill="#083c84" stroke="none" rx="0.5" />
</svg>
<div className="afl-quais-departs__header-title">Prochain départ</div>
</div>
<div className="afl-quais-departs__header-clock">{formatTimeNoSeconds(currentTime)}</div>
</div>
<div className="afl-quais-departs__no-departure">Aucun départ prévu</div>
</div>
);
const stops = getStops(nextDeparture);
let filteredStops = [];
if (stops.length > 0 && gareDisplay) {
const stationIndex = stops.findIndex(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay));
if (stationIndex !== -1) {
filteredStops = stops.slice(stationIndex + 1);
} else {
const firstStopName = normalizeName((stops[0].gare_nom || stops[0].name || stops[0].nom || '').toString());
if (firstStopName && firstStopName === normalizeName(gareDisplay)) {
filteredStops = stops.slice(1);
}
}
}
const destName = nextDeparture.gare_arrivee_nom || nextDeparture.gare_arrivee || 'Terminus';
if (filteredStops.length > 0) {
const lastStopName = normalizeName((filteredStops[filteredStops.length - 1].gare_nom || filteredStops[filteredStops.length - 1].name || filteredStops[filteredStops.length - 1].nom || '').toString());
const normDest = normalizeName(destName);
if (lastStopName !== normDest && !lastStopName.includes(normDest) && !normDest.includes(lastStopName)) {
filteredStops.push({ gare_nom: destName, isLast: true });
} else {
filteredStops[filteredStops.length - 1].isLast = true;
}
} else {
filteredStops.push({ gare_nom: destName, isLast: true });
}
// Deduplicate stops by name
const seenStops = new Set();
filteredStops = filteredStops.filter(s => {
const name = normalizeName((s.gare_nom || s.name || s.nom || s.stop_name || '').toString());
if (!name) return false;
if (seenStops.has(name)) return false;
seenStops.add(name);
return true;
});
const isSuppressed = nextDeparture._status && nextDeparture._status.label === 'supprimé';
const isDelayed = nextDeparture._status && nextDeparture._status.label && String(nextDeparture._status.label).toLowerCase().includes('retard');
let displayStatusText = "À l'heure";
if (isSuppressed) displayStatusText = "Supprimé";
else if (isDelayed) {
if (typeof nextDeparture._status.delayMinutes === 'number') {
displayStatusText = `+${nextDeparture._status.delayMinutes} min`;
} else {
displayStatusText = nextDeparture._status.label;
}
}
const displayTime = nextDeparture._displayTime || formatTimeNoSeconds(nextDeparture.heure_depart || nextDeparture.time || nextDeparture.heure || '');
const trainType = nextDeparture.type_train || nextDeparture.op || 'TER';
const trainNum = nextDeparture.numero_train || nextDeparture.numero || nextDeparture.number || '';
const stopsList = getStops(nextDeparture);
let stopForStation = null;
if (gareDisplay && Array.isArray(stopsList)) {
stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay));
}
const platformStr = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || nextDeparture.quai_reel || nextDeparture.voie || nextDeparture.quai || quaiParam || '').toString().trim();
const shouldScroll = filteredStops.length > 10;
const scrollStyle = shouldScroll ? {
animation: `scrollStops ${filteredStops.length * 2.2}s linear infinite`
} : {};
return (
<div className="afl-quais-departs__container">
{/* Header */}
<div className="afl-quais-departs__header">
<div className="afl-quais-departs__header-left">
<svg viewBox="0 0 44 24" width="6.5vh" height="4.5vh" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5vw' }}>
{/* Chevrons / motion lines */}
<path d="M4 6l6 6-6 6M12 6l6 6-6 6" stroke="white" strokeWidth="2.5" opacity="0.75" />
{/* Train icon */}
<path d="M22 17h16c1.5 0 2.5-1 2.5-2.5v-5C40.5 8 39.5 7 38 7H22c-1.5 0-2.5 1-2.5 2.5v5c0 1.5 1 2.5 2.5 2.5z" fill="white" stroke="none" />
<rect x="23" y="9" width="3" height="4" fill="#083c84" stroke="none" rx="0.5" />
<rect x="28" y="9" width="3" height="4" fill="#083c84" stroke="none" rx="0.5" />
<rect x="33" y="9" width="4" height="4" fill="#083c84" stroke="none" rx="0.5" />
</svg>
<div className="afl-quais-departs__header-title">Prochain départ</div>
</div>
<div className="afl-quais-departs__header-clock">{formatTimeNoSeconds(currentTime)}</div>
</div>
<div className="afl-quais-departs__main">
{/* Left Pane (Floating Card) */}
<div className="afl-quais-departs__left-pane">
<div className="afl-quais-departs__time-status">
<div className="afl-quais-departs__time">{displayTime}</div>
<div className={`afl-quais-departs__status-pill ${isSuppressed ? 'afl-quais-departs__status-pill--suppressed' : (isDelayed ? 'afl-quais-departs__status-pill--delayed' : '')}`}>
{isSuppressed ? (
<svg viewBox="0 0 24 24" width="2.5vh" height="2.5vh" style={{ marginRight: '0.5vw' }}>
<circle cx="12" cy="12" r="11" fill="#111" />
<path d="M16 9l-1.5-1.5L12 10.5 9.5 8 8 9.5l2.5 2.5L8 14.5 9.5 16l2.5-2.5 2.5 2.5 1.5-1.5-2.5-2.5z" fill="#ff4c5b" />
</svg>
) : isDelayed ? (
<svg viewBox="0 0 24 24" width="2.5vh" height="2.5vh" style={{ marginRight: '0.5vw' }}>
<circle cx="12" cy="12" r="11" fill="#111" />
<path d="M11 6h2v6h-2zm0 8h2v2h-2z" fill="#ff9e43" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="2.5vh" height="2.5vh" style={{ marginRight: '0.5vw' }}>
<circle cx="12" cy="12" r="11" fill="white" />
<path d="M10 16.5l-4-4 1.5-1.5 2.5 2.5 6.5-6.5 1.5 1.5-8 8z" fill="#005eb8" />
</svg>
)}
<span>{displayStatusText}</span>
</div>
</div>
<div className="afl-quais-departs__destination">{destName}</div>
<div className="afl-quais-departs__train-info">
<span className="afl-quais-departs__train-type">{trainType}</span>
<span className="afl-quais-departs__train-number">{trainNum}</span>
</div>
</div>
{/* Right Pane (Timeline) */}
<div className="afl-quais-departs__right-pane">
<div className="afl-quais-departs__stops-window">
<style>
{`
@keyframes scrollStops {
0%, 15% { transform: translateY(0); }
85%, 100% { transform: translateY(calc(-100% + 65vh)); }
}
`}
</style>
<ul className="afl-quais-departs__stops-list" style={shouldScroll ? { animation: `scrollStops ${filteredStops.length * 1.5}s ease-in-out infinite alternate` } : {}}>
{/* Main List */}
{filteredStops.map((s, i) => {
const sname = s.gare_nom || s.nom || s.name || s.stop_name || '';
const isFirst = !shouldScroll && i === 0;
const isLastItem = s.isLast || i === filteredStops.length - 1;
const isLast = !shouldScroll && isLastItem;
return (
<li key={`s1-${i}`} className={`afl-quais-departs__stop-item ${isFirst ? 'is-first' : ''} ${isLast ? 'is-last' : ''}`}>
<div className="afl-quais-departs__stop-indicator">
<div className="afl-quais-departs__stop-line"></div>
<div className={`afl-quais-departs__stop-dot ${isLastItem ? 'is-terminus' : ''}`}></div>
</div>
<div className={`afl-quais-departs__stop-name ${isLastItem ? 'is-terminus' : ''}`}>{sname}</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
{/* Symmetrical train outline under the stops list */}
<div className="afl-quais-departs__bottom-container">
<div className="afl-quais-departs__train-illustration">
<svg viewBox="0 0 260 40" width="22vw" height="3.5vh">
<path fill="none" stroke="#00c3e3" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
d="M30,35 L230,35 C242,35 248,31 254,22 L258,12 C260,8 256,4 247,4 L13,4 C4,4 0,8 2,12 L6,22 C12,31 18,35 30,35 Z" />
<line x1="55" y1="4" x2="55" y2="35" stroke="#00c3e3" strokeWidth="2" />
<line x1="82" y1="4" x2="82" y2="35" stroke="#00c3e3" strokeWidth="2" />
<line x1="109" y1="4" x2="109" y2="35" stroke="#00c3e3" strokeWidth="2" />
<line x1="136" y1="4" x2="136" y2="35" stroke="#00c3e3" strokeWidth="2" />
<line x1="163" y1="4" x2="163" y2="35" stroke="#00c3e3" strokeWidth="2" />
<line x1="190" y1="4" x2="190" y2="35" stroke="#00c3e3" strokeWidth="2" />
<line x1="217" y1="4" x2="217" y2="35" stroke="#00c3e3" strokeWidth="2" />
</svg>
</div>
</div>
{platformStr && (
<div className="afl-quais-departs__platform-box">
<div className="afl-quais-departs__platform-label">Voie</div>
<div className="afl-quais-departs__platform-value">{platformStr}</div>
</div>
)}
</div>
);
};
export default AFLDepartsQuais;