-> Implémentation des afficheurs "Sécurité"; "Informations" et "Correspondances" pour les afficheurs AFL FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT : -> REFONTE DU DESIGN DES AFFICHEURS CLASSIQUES
1065 lines
46 KiB
JavaScript
1065 lines
46 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import supabase from '../../../lib/supabase';
|
|
import { fetchAllRows } from '../../../lib/fetchAllRows';
|
|
import './AFLDepartsQuais.css';
|
|
import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
|
|
import Logo from '../../../components/Logo';
|
|
import { getSourceFromPath } from '../../../services/railwayService';
|
|
import { getSncfTimetable } from '../../../services/sncfApi';
|
|
|
|
// 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() % 24).padStart(2, '0');
|
|
const mm = String(tryDate.getMinutes()).padStart(2, '0');
|
|
return `${hh}:${mm}`;
|
|
}
|
|
const m = String(v).match(/(\d{1,2})[:h](\d{2})/i);
|
|
if (m) {
|
|
const h = parseInt(m[1], 10) % 24;
|
|
return `${String(h).padStart(2, '0')}:${m[2]}`;
|
|
}
|
|
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 gareIdParam = searchParams.get('gare_id') || searchParams.get('gareId');
|
|
const quaiParam = searchParams.get('quai') || searchParams.get('voie') || '';
|
|
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;
|
|
|
|
const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null);
|
|
useAnnonceSonoreRealtime(resolvedGareId, 'AFLDepartsQuais', quaiParam);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
const resolveGareId = async () => {
|
|
if (!supabase) return;
|
|
try {
|
|
if (gareIdParam) {
|
|
const { data: gdata } = await supabase.from('gares').select('id').eq('id', gareIdParam).limit(1);
|
|
if (mounted && gdata && gdata.length) {
|
|
setResolvedGareId(gdata[0].id);
|
|
}
|
|
} else if (gareDisplay) {
|
|
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
|
|
const { data: gdata } = await supabase.from('gares').select('id, nom').ilike('nom', flexNeedle);
|
|
if (gdata && gdata.length) {
|
|
const exactMatch = gdata.find(g => {
|
|
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
|
|
const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
|
|
return n1 === n2;
|
|
});
|
|
const chosen = exactMatch || gdata[0];
|
|
if (mounted && chosen) {
|
|
setResolvedGareId(chosen.id);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[AFLDepartsQuais] Erreur resolution gare:', e);
|
|
}
|
|
};
|
|
resolveGareId();
|
|
return () => { mounted = false; };
|
|
}, [gareIdParam, gareDisplay]);
|
|
|
|
// 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 % 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 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 source = getSourceFromPath(window.location.pathname);
|
|
if (source === 'sncf') {
|
|
const { data: sncfData } = await getSncfTimetable({
|
|
gareName: gareDisplay || gareParam,
|
|
mode: 'departs',
|
|
quai: quaiParam
|
|
});
|
|
if (sncfData && sncfData.length > 0) {
|
|
const train = sncfData[0];
|
|
const enriched = {
|
|
...train,
|
|
_rawTime: train.heure_depart,
|
|
_displayTime: formatTimeNoSeconds(train.heure_depart),
|
|
_status: {
|
|
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure"),
|
|
detail: '',
|
|
delayMinutes: train.retard_minutes || null
|
|
},
|
|
_nextStationStatus: {
|
|
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure")
|
|
},
|
|
_isToday: true
|
|
};
|
|
setNextDeparture(enriched);
|
|
} else {
|
|
setNextDeparture(null);
|
|
}
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!supabase) return;
|
|
|
|
const [{ data: horairesData }, { data: subData }, { data: perturbData }, { data: travauxData }] = await Promise.all([
|
|
fetchAllRows(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.gares_desservies === 'string') {
|
|
const parsed = JSON.parse(h.gares_desservies);
|
|
if (Array.isArray(parsed)) return { ...h, gares_desservies: 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 circulatesOn = (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 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);
|
|
if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) {
|
|
const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999));
|
|
finalList = [...finalList, ...sortedTomorrow];
|
|
}
|
|
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);
|
|
|
|
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();
|
|
if (!quaiParam) return true;
|
|
return 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, gareParam, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
const interval = setInterval(fetchData, 15000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchData]);
|
|
|
|
if (loading) return <div className="afl-q-container" style={{ background: '#0c0f2e' }}></div>;
|
|
|
|
if (!nextDeparture) {
|
|
return (
|
|
<div className="afl-q-container">
|
|
<header className="afl-q-header">
|
|
<div className="afl-q-header-left">
|
|
<svg width="56px" height="56px" viewBox="0 0 32 32" fill="none" className="afl-q-header-icon">
|
|
<path d="M4 8l5 5-5 5M11 8l5 5-5 5" stroke="white" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
<circle cx="21" cy="11" r="2.6" fill="white" />
|
|
<path d="M19 15h4.5l1.6 5.8-2 1-1-3.6-1 5.8-3-1 1.6-8z" fill="white" />
|
|
<path d="M26 6v20M26 6h2M26 26h2" stroke="white" strokeWidth="2.2" strokeLinecap="round" />
|
|
</svg>
|
|
<div className="afl-q-header-title">Prochain départ</div>
|
|
</div>
|
|
<div className="afl-q-header-clock">{formatTimeNoSeconds(currentTime)}</div>
|
|
</header>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '43px', color: '#ffffff', fontWeight: 600 }}>
|
|
Aucun départ prévu
|
|
</div>
|
|
<footer className="afl-q-footer">
|
|
{quaiParam && (
|
|
<div className="afl-q-voie-box">
|
|
<div className="afl-q-voie-label">Voie</div>
|
|
<div className="afl-q-voie-value">{quaiParam}</div>
|
|
</div>
|
|
)}
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const trainData = nextDeparture;
|
|
|
|
const isSuppressed = trainData._status && String(trainData._status.label || '').toLowerCase().includes('supprimé');
|
|
const isDelayed = trainData._status && String(trainData._status.label || '').toLowerCase().includes('retard');
|
|
|
|
let delayVal = '+ 5 min';
|
|
if (typeof trainData._status?.delayMinutes === 'number' && trainData._status.delayMinutes > 0) {
|
|
delayVal = `+ ${trainData._status.delayMinutes} min`;
|
|
} else if (trainData._status?.label && trainData._status.label.includes('+')) {
|
|
delayVal = trainData._status.label;
|
|
}
|
|
|
|
const pillClass = isSuppressed ? 'afl-q-pill--suppressed' : isDelayed ? 'afl-q-pill--delayed' : '';
|
|
const pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayVal : "À l'heure";
|
|
|
|
const renderPillIcon = () => {
|
|
if (isSuppressed) {
|
|
return (
|
|
<svg viewBox="0 0 20 20" className="afl-q-pill-icon">
|
|
<circle cx="10" cy="10" r="9.5" fill="#1e1e1e" />
|
|
<path d="M6.5 6.5l7 7M13.5 6.5l-7 7" stroke="#ff5c73" strokeWidth="2.5" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (isDelayed) {
|
|
return (
|
|
<svg viewBox="0 0 20 20" className="afl-q-pill-icon">
|
|
<circle cx="10" cy="10" r="9.5" fill="#1e1e1e" />
|
|
<line x1="10" y1="4.5" x2="10" y2="11.5" stroke="white" strokeWidth="2.4" strokeLinecap="round" />
|
|
<circle cx="10" cy="14.8" r="1.4" fill="white" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg viewBox="0 0 20 20" className="afl-q-pill-icon">
|
|
<circle cx="10" cy="10" r="9.5" fill="white" />
|
|
<path d="M6 10.2l2.6 2.6 5.4-5.4" stroke="#45527a" strokeWidth="2.8" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
const displayTime = trainData._displayTime || formatTimeNoSeconds(trainData.heure_depart || trainData.time || trainData.heure || '');
|
|
const trainType = trainData.type_train || trainData.op || 'TER';
|
|
const trainNum = trainData.numero_train || trainData.numero || trainData.number || '';
|
|
const destName = trainData.gare_arrivee_nom || trainData.gare_arrivee || 'Terminus';
|
|
|
|
const stopsList = getStops(trainData);
|
|
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)) || trainData.quai_reel || trainData.voie || trainData.quai || trainData._displayPlatform || quaiParam || '').toString().trim() || quaiParam || '1';
|
|
|
|
// Extraction des destinations (prise en compte des trains multi-branches / tranches)
|
|
let destinations = [];
|
|
if (Array.isArray(trainData.tranches) && trainData.tranches.length > 0) {
|
|
destinations = trainData.tranches.map(t => ({
|
|
name: t.gare_arrivee_nom || t.destination || t.nom,
|
|
op: t.type_train || t.op || trainType,
|
|
number: t.numero_train || t.number || trainNum
|
|
}));
|
|
} else if (destName.includes('/')) {
|
|
destinations = destName.split('/').map(d => ({
|
|
name: d.trim(),
|
|
op: trainType,
|
|
number: trainNum
|
|
}));
|
|
} else if (destName.includes(' - ') && (destName.toLowerCase().includes('marseille') || destName.toLowerCase().includes('montpellier'))) {
|
|
destinations = [
|
|
{ name: 'Marseille Saint-Charles', op: trainType, number: trainNum },
|
|
{ name: 'Montpellier Saint-Roch', op: trainType, number: trainNum }
|
|
];
|
|
} else {
|
|
destinations = [{
|
|
name: destName,
|
|
op: trainType,
|
|
number: trainNum
|
|
}];
|
|
}
|
|
|
|
// Formatage des lignes de destinations (découpage élégant sur 2 lignes si nécessaire)
|
|
const formatDestLines = (name) => {
|
|
if (!name) return ['Terminus'];
|
|
const clean = name.trim();
|
|
if (clean.includes('Marseille Saint-Charles')) return ['Marseille Saint-', 'Charles'];
|
|
if (clean.includes('Montpellier Saint-Roch')) return ['Montpellier', 'Saint-Roch'];
|
|
if (clean.includes('Paris Gare de Lyon')) return ['Paris', 'Gare de Lyon'];
|
|
if (clean.includes('Paris Montparnasse')) return ['Paris', 'Montparnasse'];
|
|
if (clean.includes('Bourg-en-Bresse')) return ['Bourg-en-', 'Bresse'];
|
|
if (clean.length > 12) {
|
|
const parts = clean.split(' ');
|
|
if (parts.length >= 2) {
|
|
const mid = Math.ceil(parts.length / 2);
|
|
return [parts.slice(0, mid).join(' '), parts.slice(mid).join(' ')];
|
|
}
|
|
}
|
|
return [clean];
|
|
};
|
|
|
|
// Calcul dynamique des arrêts desservis (aval)
|
|
const currentStationName = gareDisplay || (stopForStation && (stopForStation.gare_nom || stopForStation.nom || stopForStation.name)) || (stopsList[0] && (stopsList[0].gare_nom || stopsList[0].nom || stopsList[0].name)) || 'Départ';
|
|
|
|
let downstreamStops = [];
|
|
if (Array.isArray(stopsList) && stopsList.length > 0) {
|
|
const normCurrent = normalizeName(currentStationName);
|
|
const startIdx = stopsList.findIndex(s => {
|
|
const sName = normalizeName((s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : '')).toString());
|
|
return sName && (sName === normCurrent || sName.includes(normCurrent) || normCurrent.includes(sName));
|
|
});
|
|
|
|
const rawDownstream = startIdx !== -1 ? stopsList.slice(startIdx + 1) : stopsList;
|
|
|
|
const seen = new Set();
|
|
rawDownstream.forEach(s => {
|
|
const name = (s.gare_nom || s.nom || s.name || s.station_name || s.stop_name || (typeof s === 'string' ? s : '')).toString().trim();
|
|
const norm = normalizeName(name);
|
|
if (name && norm !== normCurrent && !seen.has(norm)) {
|
|
seen.add(norm);
|
|
downstreamStops.push(name);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Si aucun arrêt intermédiaire n'est trouvé, afficher la/les destination(s)
|
|
if (downstreamStops.length === 0) {
|
|
if (destName) {
|
|
if (destName.includes('/')) {
|
|
downstreamStops = destName.split('/').map(d => d.trim());
|
|
} else {
|
|
downstreamStops = [destName];
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="afl-q-container">
|
|
{/* HEADER (10.5vh) */}
|
|
<header className="afl-q-header">
|
|
<div className="afl-q-header-left">
|
|
<svg width="56px" height="56px" viewBox="0 0 32 32" fill="none" className="afl-q-header-icon">
|
|
<path d="M4 8l5 5-5 5M11 8l5 5-5 5" stroke="white" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
<circle cx="21" cy="11" r="2.6" fill="white" />
|
|
<path d="M19 15h4.5l1.6 5.8-2 1-1-3.6-1 5.8-3-1 1.6-8z" fill="white" />
|
|
<path d="M26 6v20M26 6h2M26 26h2" stroke="white" strokeWidth="2.2" strokeLinecap="round" />
|
|
</svg>
|
|
<div className="afl-q-header-title">Prochain départ</div>
|
|
</div>
|
|
<div className="afl-q-header-clock">{formatTimeNoSeconds(currentTime)}</div>
|
|
</header>
|
|
|
|
{/* CORPS PRINCIPAL */}
|
|
<main className="afl-q-main">
|
|
{/* PANNEAU GAUCHE : CARTE D'INFORMATION DU TRAIN */}
|
|
<section className="afl-q-left-card">
|
|
{/* Ligne Heure de départ & Statut */}
|
|
<div className="afl-q-card-top">
|
|
<div className="afl-q-card-time">{displayTime}</div>
|
|
<div className={`afl-q-pill ${pillClass}`}>
|
|
{renderPillIcon()}
|
|
<span>{pillText}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Blocs Destinations dynamiques selon l'horaire */}
|
|
{destinations.map((d, i) => {
|
|
const lines = formatDestLines(d.name);
|
|
return (
|
|
<div key={i} className="afl-q-dest-block">
|
|
{lines.map((line, lIdx) => (
|
|
<div key={lIdx} className="afl-q-dest-title">{line}</div>
|
|
))}
|
|
<div className="afl-q-dest-sub">
|
|
{d.op} {d.number}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Pied de carte : Logo du type de train (sans icônes de services) */}
|
|
<div className="afl-q-card-footer">
|
|
<Logo
|
|
op={trainType}
|
|
classNameBox="afl-q-train-logo-box"
|
|
classNameImg="afl-q-train-logo-img"
|
|
classNameText="afl-q-train-logo-text"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* PANNEAU DROIT : TIMELINE ARBORESCENTE DYNAMIQUE */}
|
|
<section className="afl-q-timeline-area">
|
|
<svg viewBox="0 0 850 560" className="afl-q-timeline-svg">
|
|
{/* 3 mini-points cyan au sommet */}
|
|
<circle cx="45" cy="22" r="4" fill="#00c5ff" />
|
|
<circle cx="45" cy="38" r="4" fill="#00c5ff" />
|
|
<circle cx="45" cy="54" r="4" fill="#00c5ff" />
|
|
|
|
{/* Station 1: Gare actuelle */}
|
|
<circle cx="45" cy="92" r="17" fill="#0c102a" stroke="#00c5ff" strokeWidth="4" />
|
|
<path d="M 37 89 L 45 97 L 53 89" stroke="#00c5ff" strokeWidth="3.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
<text x="82" y="102" fill="#ffffff" fontSize="35" fontWeight="800" fontFamily="Achemine, Segoe UI, sans-serif">
|
|
{currentStationName}
|
|
</text>
|
|
|
|
{/* Tracé dynamique des arrêts aval */}
|
|
{downstreamStops.length <= 1 ? (
|
|
<g>
|
|
<line x1="45" y1="109" x2="45" y2="480" stroke="#00c5ff" strokeWidth="5.5" strokeLinecap="round" />
|
|
<circle cx="45" cy="480" r="16" fill="#0c102a" stroke="#00c5ff" strokeWidth="5" />
|
|
<text x="82" y="490" fill="#ffffff" fontSize="35" fontWeight="800" fontFamily="Achemine, Segoe UI, sans-serif">
|
|
{downstreamStops[0] || destName}
|
|
</text>
|
|
</g>
|
|
) : (
|
|
(() => {
|
|
const N = downstreamStops.length;
|
|
const yStart = 160;
|
|
const yEnd = 500;
|
|
const step = Math.min(65, (yEnd - yStart) / Math.max(1, N - 1));
|
|
const lineEndY = yStart + (N - 1) * step;
|
|
|
|
return (
|
|
<g>
|
|
<line x1="45" y1="109" x2="45" y2={lineEndY} stroke="#00c5ff" strokeWidth="5.5" strokeLinecap="round" />
|
|
{downstreamStops.map((stopName, sIdx) => {
|
|
const isLast = sIdx === N - 1;
|
|
const posY = yStart + sIdx * step;
|
|
|
|
if (!isLast) {
|
|
return (
|
|
<g key={sIdx}>
|
|
<circle cx="45" cy={posY} r="6.5" fill="#00c5ff" />
|
|
<text x="82" y={posY + 8} fill="#ffffff" fontSize="28" fontWeight="500" fontFamily="Achemine, Segoe UI, sans-serif">
|
|
{stopName}
|
|
</text>
|
|
</g>
|
|
);
|
|
}
|
|
return (
|
|
<g key={sIdx}>
|
|
<circle cx="45" cy={posY} r="16" fill="#0c102a" stroke="#00c5ff" strokeWidth="5" />
|
|
<text x="82" y={posY + 10} fill="#ffffff" fontSize="35" fontWeight="800" fontFamily="Achemine, Segoe UI, sans-serif">
|
|
{stopName}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
})()
|
|
)}
|
|
</svg>
|
|
</section>
|
|
</main>
|
|
|
|
{/* FOOTER : BOX VOIE EN BAS À DROITE (SANS REPÈRES DE QUAI NI COMPOSITION) */}
|
|
<footer className="afl-q-footer">
|
|
<div className="afl-q-voie-box">
|
|
<div className="afl-q-voie-label">Voie</div>
|
|
<div className="afl-q-voie-value">{platformStr}</div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AFLDepartsQuais; |