987 lines
55 KiB
JavaScript
987 lines
55 KiB
JavaScript
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
import './ClassiquesArriveesQuais.css';
|
|
import '../ClassiquesArrivees.css';
|
|
|
|
/*
|
|
ClassiquesArriveesQuais.js
|
|
Variante "Arrivées - quais" du composant quais.
|
|
|
|
Différences principales avec la version départs:
|
|
- utilise `heure_arrivee` / `gare_arrivee_*` lorsque disponible pour afficher l'heure et la destination
|
|
- watermark affiché: "arrivée"
|
|
- styles importés depuis `ClassiquesArriveesQuais.css` qui reprend la palette de `ClassiquesArrivees.css`
|
|
|
|
Champs utilisés pour le calcul/affichage:
|
|
- horaire.heure_arrivee, horaire.time, horaire.heure
|
|
- horaire.gare_arrivee_nom, horaire.gare_arrivee
|
|
- stop.heure_arrivee, stop.gare_nom, stop.gare_id
|
|
- perturbations: mêmes clés que pour les départs (retard, retard_minutes, perturbations_arrets, etc.)
|
|
*/
|
|
import supabase from '../../../../lib/supabase';
|
|
|
|
// Hook pour récupérer la map des logos depuis /type/data.json
|
|
function useTypeLogos(){
|
|
const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' });
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
const load = async () => {
|
|
const candidates = ['/type/quais/data-color.json', '/type/data.json'];
|
|
for(const url of candidates){
|
|
try{
|
|
const res = await fetch(url);
|
|
if(!res.ok) continue;
|
|
const data = await res.json();
|
|
if(!mounted) return;
|
|
const logos = {};
|
|
if(Array.isArray(data.logos)){
|
|
data.logos.forEach(item => {
|
|
// item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path
|
|
let file = item.file;
|
|
if(!file && item.path){
|
|
const parts = String(item.path).split('/');
|
|
file = parts[parts.length-1];
|
|
}
|
|
if(!file) return;
|
|
const basename = file.replace(/\.[^/.]+$/, '');
|
|
const semantic = basename.replace(/^logo-/, '');
|
|
const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-');
|
|
// Prefer color logos in the 'quais' subfolder if data came from the quais file
|
|
if(url.includes('/quais/')){
|
|
logos[key] = `/type/quais/${file}`;
|
|
} else {
|
|
logos[key] = `/type/${file}`;
|
|
}
|
|
});
|
|
}
|
|
logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png';
|
|
setMap(logos);
|
|
return;
|
|
}catch(e){
|
|
// try next candidate (ignore error and continue loop)
|
|
}
|
|
}
|
|
// no candidate succeeded — keep fallback
|
|
};
|
|
|
|
load();
|
|
return () => { mounted = false; };
|
|
}, []);
|
|
return map;
|
|
}
|
|
|
|
function getTypeLogo(type, TYPE_LOGOS_MAP){
|
|
const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' };
|
|
if(!type) return map.default;
|
|
const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-');
|
|
if(map[norm]) return map[norm];
|
|
const keys = Object.keys(map).filter(k => k !== 'default');
|
|
for(const k of keys){
|
|
if(k.includes(norm) || norm.includes(k)) return map[k];
|
|
}
|
|
return map.default;
|
|
}
|
|
|
|
function Clock() {
|
|
const [now, setNow] = useState(new Date());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(new Date()), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
const hh = String(now.getHours()).padStart(2,'0');
|
|
const mm = String(now.getMinutes()).padStart(2,'0');
|
|
const ss = String(now.getSeconds()).padStart(2,'0');
|
|
return (
|
|
<div className="aff-quais__clock">
|
|
<div className="aff-quais__clock-box"><span className="aff-quais__clock-main">{hh}:{mm}:{ss}</span></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ClassiquesArriveesQuais(){
|
|
const typeLogos = useTypeLogos();
|
|
|
|
const rightRef = useRef(null);
|
|
const stopsListRef = useRef(null);
|
|
const railRef = useRef(null);
|
|
|
|
// fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée)
|
|
const mock = {
|
|
time: '16h44',
|
|
status: "à l'heure",
|
|
trainType: 'Fluo',
|
|
trainNumber: '891811',
|
|
destinationBig: 'Seurre',
|
|
destinationStation: '',
|
|
stops: [
|
|
'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse'
|
|
],
|
|
};
|
|
|
|
// état pour l'horaire sélectionné
|
|
const [selectedHoraire, setSelectedHoraire] = useState(null);
|
|
const [loadingHoraire, setLoadingHoraire] = useState(false);
|
|
const [horaireError, setHoraireError] = useState(null);
|
|
const [candidatesCount, setCandidatesCount] = useState(0);
|
|
// type de la gare (récupéré depuis la table gares)
|
|
const [stationType, setStationType] = useState(null);
|
|
// Heure actuelle mise à jour chaque seconde pour la logique de seuil
|
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(() => setCurrentTime(new Date()), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// perturbations/travaux (récupérées depuis Supabase)
|
|
const [perturbations, setPerturbations] = useState([]);
|
|
const [, setTravaux] = useState([]);
|
|
|
|
// helpers locaux (simples) — similaires à ceux du composant principal
|
|
const weekdayNameForColumn = useCallback((d) => {
|
|
const i = d.getDay();
|
|
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 = useCallback((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`, // Jour de l'an
|
|
`${y}-05-01`, // Fête du Travail
|
|
`${y}-05-08`, // Victoire 1945
|
|
`${y}-07-14`, // Fête nationale
|
|
`${y}-08-15`, // Assomption
|
|
`${y}-11-01`, // Toussaint
|
|
`${y}-11-11`, // Armistice 1918
|
|
`${y}-12-25`, // Noël
|
|
];
|
|
// Dates variables pour 2026
|
|
if (y === 2026) {
|
|
holidays.push('2026-04-06'); // Lundi de Pâques
|
|
holidays.push('2026-05-14'); // Ascension
|
|
holidays.push('2026-05-25'); // Lundi de Pentecôte
|
|
}
|
|
return holidays.includes(s);
|
|
}, []);
|
|
|
|
const circulatesOn = useCallback((h, d) => {
|
|
if (isPublicHoliday(d)) {
|
|
const v = h['circule_jours_feries'];
|
|
if (typeof v === 'boolean') return v;
|
|
if (typeof v === 'number') return v === 1;
|
|
if (typeof v === 'string') {
|
|
const s = v.trim().toLowerCase();
|
|
return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes';
|
|
}
|
|
return (typeof v === 'undefined' || v === null) ? false : Boolean(v);
|
|
}
|
|
const col = weekdayNameForColumn(d);
|
|
if (!col) return true;
|
|
const v = h[col];
|
|
if (typeof v === 'boolean') return v;
|
|
if (typeof v === 'number') return v === 1;
|
|
if (typeof v === 'string') {
|
|
const s = v.trim().toLowerCase();
|
|
return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes';
|
|
}
|
|
return (typeof v === 'undefined' || v === null) ? true : Boolean(v);
|
|
}, [isPublicHoliday, weekdayNameForColumn]);
|
|
|
|
const normalizeName = useCallback((s) => {
|
|
if (!s) return '';
|
|
try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); }
|
|
}, []);
|
|
|
|
const parseTimeHM = useCallback((s) => {
|
|
if (!s) return null;
|
|
if (typeof s === 'number') return s;
|
|
const m = String(s).match(/(\d{1,2}):(\d{2})/);
|
|
if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);
|
|
const d = new Date(s);
|
|
if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();
|
|
return null;
|
|
}, []);
|
|
|
|
const formatTimeNoSeconds = useCallback((v) => {
|
|
if (v == null) return '';
|
|
const tryDate = new Date(v);
|
|
if (!isNaN(tryDate.getTime())) {
|
|
const hh = String(tryDate.getHours()).padStart(2,'0');
|
|
const mm = String(tryDate.getMinutes()).padStart(2,'0');
|
|
return `${hh}h${mm}`;
|
|
}
|
|
if (typeof v === 'string') {
|
|
const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);
|
|
if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;
|
|
const m2 = v.match(/T(\d{2}):(\d{2})/);
|
|
if (m2) return `${m2[1]}h${m2[2]}`;
|
|
return v.slice(0,5).replace(':','h');
|
|
}
|
|
return String(v).slice(0,5).replace(':','h');
|
|
}, []);
|
|
|
|
// ...existing code...
|
|
|
|
const formatTimeFromHM = useCallback((totalMinutes) => {
|
|
if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';
|
|
const m = ((totalMinutes % 1440) + 1440) % 1440;
|
|
const hh = String(Math.floor(m / 60)).padStart(2, '0');
|
|
const mm = String(m % 60).padStart(2, '0');
|
|
return `${hh}h${mm}`;
|
|
}, []);
|
|
|
|
// retourne le threshold (en minutes) pour afficher le quai selon le type de gare
|
|
const thresholdForStationType = useCallback((type) => {
|
|
if (!type) return 0;
|
|
const t = String(type).trim().toLowerCase();
|
|
if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures
|
|
if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes
|
|
return 0;
|
|
}, []);
|
|
|
|
// lecture des query params pour gare & quai
|
|
const paramsForThis = new URLSearchParams(window.location.search || '');
|
|
const gareParam = paramsForThis.get('gare');
|
|
const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId');
|
|
const quaiParam = paramsForThis.get('quai');
|
|
const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true');
|
|
const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null;
|
|
|
|
// helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs)
|
|
const getStops = (h) => {
|
|
if (!h) return [];
|
|
if (Array.isArray(h.stops) && h.stops.length) return h.stops;
|
|
if (Array.isArray(h.gares_desservies) && h.gares_desservies.length) return h.gares_desservies;
|
|
if (Array.isArray(h.garesDesservies) && h.garesDesservies.length) return h.garesDesservies;
|
|
if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets;
|
|
const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','gares_desservies_json'];
|
|
for (const key of candidates) {
|
|
const raw = h[key];
|
|
if (!raw) continue;
|
|
if (Array.isArray(raw)) return raw;
|
|
if (typeof raw === 'string') {
|
|
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
// --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) ---
|
|
const safeParseArrets = (p) => {
|
|
const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations);
|
|
if (!raw) return [];
|
|
if (Array.isArray(raw)) return raw;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object') {
|
|
if (Array.isArray(parsed.arrets)) return parsed.arrets;
|
|
return parsed;
|
|
}
|
|
return [];
|
|
} catch (e) { return []; }
|
|
};
|
|
|
|
const parsePerturbArretsObject = 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 buildPerturbMap = useCallback((list = []) => {
|
|
const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };
|
|
(list || []).forEach(p => {
|
|
const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;
|
|
if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }
|
|
const number = p.number || p.train_number || p.train || p.numero || p.numero_train;
|
|
if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }
|
|
const arrets = safeParseArrets(p);
|
|
if (Array.isArray(arrets) && arrets.length) {
|
|
arrets.forEach(a => {
|
|
const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;
|
|
const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
|
|
if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }
|
|
if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }
|
|
});
|
|
}
|
|
if ((horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }
|
|
});
|
|
return idx;
|
|
}, [normalizeName]);
|
|
|
|
const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => {
|
|
const res = [];
|
|
const map = idx || buildPerturbMap(perturbations || []);
|
|
const hId = horaire && (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);
|
|
const number = horaire && (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);
|
|
if (hId != null) { const kh = String(hId); if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); }
|
|
if (number != null) { const kn = String(number); if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); }
|
|
if (stationId != null) { const ks = String(stationId); if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); }
|
|
if (stationName && map.byStopName) {
|
|
const key = normalizeName(stationName);
|
|
if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation));
|
|
try {
|
|
const needle = key;
|
|
for (const existingKey of Object.keys(map.byStopName || {})) {
|
|
if (existingKey === needle) continue;
|
|
if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); }
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
return Array.from(new Set(res));
|
|
}, [buildPerturbMap, perturbations, normalizeName]);
|
|
|
|
const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => {
|
|
const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];
|
|
const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };
|
|
let horaireYmd = toYMD(horaire && (horaire._date || horaire.date || horaire.date_service || horaire.service_date));
|
|
if (!horaireYmd && serviceDate) horaireYmd = serviceDate;
|
|
let maxDelay = null;
|
|
for (const p of found) {
|
|
try {
|
|
if (horaireYmd) {
|
|
const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);
|
|
const db = toYMD(p.date_debut || p.start_date);
|
|
const df = toYMD(p.date_fin || p.end_date);
|
|
if (pDate && pDate !== horaireYmd) continue;
|
|
if (db && df && !(horaireYmd >= db && horaireYmd <= df)) continue;
|
|
}
|
|
} catch (e) {}
|
|
try {
|
|
const joursSet = getPerturbationJours(p);
|
|
if (joursSet && horaireYmd) { if (!joursSet.has(horaireYmd)) continue; }
|
|
} catch (e) {}
|
|
const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };
|
|
const arrets = parsedObj.arrets || [];
|
|
let matched = false;
|
|
for (const a of arrets) {
|
|
const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;
|
|
const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
|
|
const na = normalizeName(aName || '');
|
|
const ns = normalizeName(stationName || '');
|
|
const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na));
|
|
if ((stationId != null && String(aId) === String(stationId)) || nameMatches) {
|
|
matched = true;
|
|
const t = (a.type_perturbation || a.type || '').toString().toLowerCase();
|
|
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };
|
|
if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {
|
|
const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;
|
|
if (maxDelay == null || d > maxDelay) maxDelay = d;
|
|
}
|
|
}
|
|
}
|
|
if (!matched) {
|
|
const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;
|
|
const numberRef = p.number || p.train_number || p.numero || p.numero_train;
|
|
const appliesByRef = ref && (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));
|
|
const appliesByNumber = numberRef && (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));
|
|
const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef;
|
|
if (appliesByRef || appliesByNumber || isGlobal) {
|
|
const t = (p.type_perturbation || p.type || '').toString().toLowerCase();
|
|
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };
|
|
if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {
|
|
const d = parseDelayMinutesFromArret(p) || 0;
|
|
if (maxDelay == null || d > maxDelay) maxDelay = d;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };
|
|
return { label: "à l'heure", detail: '', delayMinutes: null };
|
|
}, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName, parsePerturbArretsObject]);
|
|
|
|
// Fetch perturbations & travaux from Supabase periodically
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
let inFlight = false;
|
|
const fetchPerturb = async () => {
|
|
if (!mounted) return;
|
|
if (inFlight) return;
|
|
inFlight = true;
|
|
try {
|
|
if (!supabase) return;
|
|
const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([
|
|
supabase.from('perturbations').select('*'),
|
|
supabase.from('travaux').select('*')
|
|
]);
|
|
if (!mounted) return;
|
|
if (pError || tError) {
|
|
// ignore quietly — keep previous data
|
|
} else {
|
|
// Filtrer les perturbations pour ne conserver que celles valides aujourd'hui
|
|
const todayYmd = new Date().toLocaleDateString('en-CA');
|
|
const filteredPerturb = (perturbData || []).filter(p => {
|
|
try {
|
|
const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };
|
|
const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);
|
|
const db = toYMD(p.date_debut || p.start_date);
|
|
const df = toYMD(p.date_fin || p.end_date);
|
|
if (pDate && pDate !== todayYmd) return false;
|
|
if (db && df && !(todayYmd >= db && todayYmd <= df)) return false;
|
|
try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(todayYmd)) return false; } catch (e) {}
|
|
return true;
|
|
} catch (e) { return true; }
|
|
});
|
|
setPerturbations(filteredPerturb || []);
|
|
setTravaux(travauxData || []);
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
};
|
|
fetchPerturb();
|
|
const id = setInterval(fetchPerturb, 15000);
|
|
return () => { mounted = false; clearInterval(id); };
|
|
}, [getPerturbationJours]);
|
|
|
|
// Annotate selectedHoraire with computed status when perturbations change
|
|
useEffect(() => {
|
|
if (!selectedHoraire || !selectedHoraire.horaire) return;
|
|
const horaire = selectedHoraire.horaire;
|
|
const stop = selectedHoraire.stop || null;
|
|
const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null;
|
|
const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null;
|
|
try {
|
|
const map = buildPerturbMap(perturbations || []);
|
|
const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null);
|
|
// keep original scheduled time for display (_displayTime)
|
|
const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure);
|
|
const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null));
|
|
// compute updated time (delayed) but store separately — do not override the original displayed time
|
|
let updatedTime = null;
|
|
if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') {
|
|
updatedTime = formatTimeFromHM(originalBase + status.delayMinutes);
|
|
}
|
|
setSelectedHoraire(prev => ({ ...(prev||{}), horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime }));
|
|
} catch (e) { /* ignore */ }
|
|
}, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]);
|
|
|
|
// récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai)
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
async function loadNextForQuai() {
|
|
setLoadingHoraire(true);
|
|
setHoraireError(null);
|
|
setSelectedHoraire(null);
|
|
if (!supabase) {
|
|
setHoraireError('Supabase non initialisé');
|
|
setLoadingHoraire(false);
|
|
return;
|
|
}
|
|
try {
|
|
// Récupérer le type de la gare
|
|
let fetchedType = null;
|
|
try {
|
|
if (gareIdParam) {
|
|
const { data: g } = await supabase.from('gares').select('type').eq('gare_id', gareIdParam).single();
|
|
if (g) fetchedType = g.type;
|
|
} else if (gareDisplayParam) {
|
|
const { data: g } = await supabase.from('gares').select('type').ilike('nom', `%${gareDisplayParam}%`).limit(1).single();
|
|
if (g) fetchedType = g.type;
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
if (mounted) setStationType(fetchedType);
|
|
|
|
const { data: all, error } = await supabase.from('horaires').select('id, *');
|
|
if (error) {
|
|
if (mounted) setHoraireError(error.message || String(error));
|
|
setLoadingHoraire(false);
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
if (!Array.isArray(all) || all.length === 0) {
|
|
setLoadingHoraire(false);
|
|
return;
|
|
}
|
|
|
|
const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null;
|
|
const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null;
|
|
|
|
const now = new Date();
|
|
const 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;
|
|
|
|
// Filtrage par jour de circulation
|
|
const circulatesToday = circulatesOn(h, now);
|
|
const circulatesTomorrow = circulatesOn(h, tomorrow);
|
|
if (!circulatesToday && !circulatesTomorrow) continue;
|
|
|
|
// build stops list (support multiple field names)
|
|
const stops = getStops(h);
|
|
|
|
// compute max index for terminus detection if stops contain index
|
|
let maxIdx = null;
|
|
if (Array.isArray(stops) && stops.length) {
|
|
for (const st of stops) {
|
|
if (typeof st.index !== 'undefined' && st.index !== null) {
|
|
const n = Number(st.index);
|
|
if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Match stop by name or id (prefer stop entry over horaire-level arrival)
|
|
let stopMatch = null;
|
|
if (Array.isArray(stops) && stops.length) {
|
|
// first try to match by id if gareIdParam present
|
|
if (gareIdParam) {
|
|
const gid = String(gareIdParam);
|
|
for (const s of stops) {
|
|
const sid = s && (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null);
|
|
if (sid != null && String(sid) === gid) { stopMatch = s; break; }
|
|
}
|
|
}
|
|
// then try to match by normalized name
|
|
if (!stopMatch && normalizedTarget) {
|
|
for (const s of stops) {
|
|
const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || s.station_name || '');
|
|
const nm = normalizeName(rawName);
|
|
if (!nm) continue;
|
|
if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { stopMatch = s; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
// if no stopMatch found in stops, check horaire-level gare_arrivee (arrival terminus)
|
|
if (!stopMatch) {
|
|
const arriveeName = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || '');
|
|
if (arriveeName && normalizedTarget && (arriveeName === normalizedTarget || arriveeName.includes(normalizedTarget) || normalizedTarget.includes(arriveeName))) {
|
|
stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee, gare_id: h.gare_arrivee_id || h.gare_arrivee };
|
|
}
|
|
}
|
|
|
|
if (!stopMatch) continue; // does not arrive at the targeted station
|
|
|
|
// determine platform/quai for this stop/horaire
|
|
const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || '';
|
|
const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,'');
|
|
// Only include horaires that have a platform/quai for the displayed station
|
|
if (!normPlatform) continue;
|
|
if (targetQuai) {
|
|
if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue;
|
|
}
|
|
|
|
// ensure we have an arrival time
|
|
const stopIsObject = stopMatch && typeof stopMatch === 'object';
|
|
const hasArrival = stopIsObject && (
|
|
typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined'
|
|
);
|
|
if (stopIsObject && !hasArrival) continue;
|
|
|
|
// pick time (arrival) from stopMatch or fallback to horaire-level arrival
|
|
const rawTime = (hasArrival ? (stopMatch.heure_arrivee || stopMatch.time || stopMatch.heure || stopMatch.arrival_time || stopMatch.arrival) : (h.heure_arrivee || h.time || h.heure || h.arrival_time || h.arrivee));
|
|
const depHM = parseTimeHM(rawTime);
|
|
if (depHM == null) continue;
|
|
|
|
// compute minutes until arrival (allow next-day wrap)
|
|
let minutesUntil = depHM - nowHM;
|
|
if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM;
|
|
|
|
candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime, circulatesToday, circulatesTomorrow });
|
|
}
|
|
|
|
if (!candidates.length) {
|
|
setCandidatesCount(0);
|
|
setLoadingHoraire(false);
|
|
setSelectedHoraire(null);
|
|
return;
|
|
}
|
|
|
|
// split into today (circulatesToday AND depHM > nowHM) and tomorrow (circulatesTomorrow AND (depHM <= nowHM OR not circulatesToday))
|
|
const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM);
|
|
const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM <= nowHM || !c.circulatesToday)).sort((a,b) => a.depHM - b.depHM);
|
|
|
|
let chosen = null;
|
|
if (todayCandidates.length > 0) chosen = todayCandidates[0];
|
|
else if (tomorrowCandidates.length > 0) chosen = tomorrowCandidates[0];
|
|
|
|
if (mounted) {
|
|
setCandidatesCount(candidates.length);
|
|
setSelectedHoraire(chosen || null);
|
|
}
|
|
} catch (err) {
|
|
console.error('Erreur supabase quai', err);
|
|
if (mounted) setHoraireError(err.message || String(err));
|
|
} finally {
|
|
if (mounted) setLoadingHoraire(false);
|
|
}
|
|
}
|
|
|
|
loadNextForQuai();
|
|
return () => { mounted = false; };
|
|
}, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName, circulatesOn]);
|
|
|
|
// toggle flag to alternate time/status when needed (observe selectedHoraire.status when present)
|
|
const [showTime, setShowTime] = useState(true);
|
|
// derive a simple status label string to use in the dependency array (avoids complex expressions)
|
|
const _selectedStatusLabel = (() => {
|
|
const horaireObj = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
|
|
const computedLabel = horaireObj && horaireObj._status && horaireObj._status.label ? String(horaireObj._status.label) : null;
|
|
const rawStatus = horaireObj && horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status && horaireObj.status.label ? String(horaireObj.status.label) : null)) : null;
|
|
return String(computedLabel || rawStatus || mock.status || '').toLowerCase();
|
|
})();
|
|
|
|
useEffect(()=>{
|
|
let id = null;
|
|
if (/retard|supprim/.test(_selectedStatusLabel)) {
|
|
// ensure we start by showing the time
|
|
setShowTime(true);
|
|
id = setInterval(() => setShowTime(prev => !prev), 3000);
|
|
} else {
|
|
setShowTime(true);
|
|
}
|
|
return () => { if (id) clearInterval(id); };
|
|
}, [_selectedStatusLabel, mock.status]);
|
|
|
|
// compute and position the rail so it covers from first stop center to last stop center
|
|
useEffect(() => {
|
|
function updateRail(){
|
|
const rail = railRef.current;
|
|
const list = stopsListRef.current;
|
|
const right = rightRef.current;
|
|
if(!rail || !list || !right) return;
|
|
const items = Array.from(list.querySelectorAll('li'));
|
|
if(items.length === 0) return;
|
|
const first = items[0];
|
|
// find destination (last with dest class) or fallback to last item
|
|
const last = items.find(el => el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1];
|
|
|
|
const rightRect = right.getBoundingClientRect();
|
|
const firstRect = first.getBoundingClientRect();
|
|
const lastRect = last.getBoundingClientRect();
|
|
|
|
const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top;
|
|
const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top;
|
|
|
|
// add small padding so rail extends a bit beyond centers
|
|
const pad = 9;
|
|
const top = Math.max(0, firstCenterY - pad);
|
|
const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2);
|
|
|
|
// compute horizontal position so dots are centered next to the text
|
|
const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text');
|
|
const textRect = firstText ? firstText.getBoundingClientRect() : firstRect;
|
|
const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px
|
|
const railWidth = 35;
|
|
const left = centerX - railWidth/2;
|
|
|
|
// apply styles for the rail rectangle
|
|
rail.style.top = `${Math.round(top)}px`;
|
|
rail.style.height = `${Math.round(height)}px`;
|
|
rail.style.left = `${Math.round(left)}px`;
|
|
rail.style.width = `${railWidth}px`;
|
|
// remove background-image reliance and render individual dots as DOM children
|
|
// clear existing dots
|
|
while (rail.firstChild) rail.removeChild(rail.firstChild);
|
|
|
|
// create a dot element for each item and position it exactly at the item's vertical center
|
|
const railWidthComputed = parseInt(rail.style.width, 8) || railWidth;
|
|
items.forEach(it => {
|
|
const r = it.getBoundingClientRect();
|
|
const centerY = r.top + r.height/2 - (rightRect.top + top);
|
|
const dot = document.createElement('span');
|
|
dot.className = 'aff-quais__rail-dot';
|
|
dot.style.position = 'absolute';
|
|
dot.style.left = '50%';
|
|
dot.style.transform = 'translate(-50%, -50%)';
|
|
dot.style.top = `${Math.round(centerY)}px`;
|
|
// set dot diameter to rail width
|
|
dot.style.width = `${railWidthComputed}px`;
|
|
dot.style.height = `${railWidthComputed}px`;
|
|
dot.style.borderRadius = '50%';
|
|
dot.style.background = '#ffe600';
|
|
dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset';
|
|
rail.appendChild(dot);
|
|
});
|
|
}
|
|
|
|
updateRail();
|
|
window.addEventListener('resize', updateRail);
|
|
const obs = new MutationObserver(updateRail);
|
|
const listNode = stopsListRef.current;
|
|
if(listNode) obs.observe(listNode, { childList:true, subtree:true });
|
|
return ()=>{
|
|
window.removeEventListener('resize', updateRail);
|
|
if(obs) obs.disconnect();
|
|
};
|
|
}, [selectedHoraire]);
|
|
// Observer changement des stops via selectedHoraire
|
|
// Re-run rail effect lorsque selectedHoraire change (les stops affichés changent)
|
|
|
|
const displayTimeOrStatus = () => {
|
|
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
|
|
const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status;
|
|
const statusObj = horaire && horaire._status ? horaire._status : null;
|
|
// prefer precomputed _displayTime if available
|
|
const time = selectedHoraire && selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time);
|
|
|
|
// build canonical status label when we have computed status object
|
|
let canonicalStatus = null;
|
|
if (statusObj) {
|
|
if (typeof statusObj.delayMinutes === 'number' && !Number.isNaN(statusObj.delayMinutes)) {
|
|
canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`;
|
|
} else if (statusObj.label && String(statusObj.label).toLowerCase().includes('supprim')) {
|
|
canonicalStatus = 'supprimé';
|
|
} else if (statusObj.label) {
|
|
canonicalStatus = String(statusObj.label);
|
|
}
|
|
}
|
|
|
|
const s = (canonicalStatus || statusStrFromField || '').toLowerCase();
|
|
const isAlert = s.includes('retard') || s.includes('supprim');
|
|
const label = canonicalStatus || statusStrFromField || '';
|
|
|
|
// Logique de seuil d'affichage du quai
|
|
const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes();
|
|
const sched = selectedHoraire && selectedHoraire.depHM ? selectedHoraire.depHM : null;
|
|
const threshold = thresholdForStationType(stationType);
|
|
const showPlatform = (sched !== null) ? (nowHM >= (sched - threshold)) : true;
|
|
|
|
if (!showPlatform) {
|
|
return (
|
|
<span className="aff-quais__time-status-wrapper" aria-live="polite">
|
|
<span className="aff-quais__time aff-quais__time--strict visible">--h--</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Always render both time and status inside a fixed-size wrapper to avoid layout shift
|
|
return (
|
|
<span className="aff-quais__time-status-wrapper" aria-live="polite">
|
|
<span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}>{time}</span>
|
|
<span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime && isAlert) ? 'visible' : 'hidden'}`}>{label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="aff-quais__container aff-quais__container--strict aff-quais__container--arrivees">
|
|
<div className="aff-quais__left aff-quais__left--strict">
|
|
{/* logo (depuis le type de train) */}
|
|
{(() => {
|
|
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
|
|
const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;
|
|
return <img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className="aff-quais__type-logo--strict" />;
|
|
})()}
|
|
|
|
<div className="aff-quais__watermark" aria-hidden>départ</div>
|
|
{/* affichage des états de chargement / erreur pour debug */}
|
|
{horaireError ? <div className="aff-quais__error" style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}>{horaireError}</div> : null}
|
|
{loadingHoraire ? <div className="aff-quais__loading" style={{color:'#666',fontSize:'0.85rem',marginTop:6}}>Chargement...</div> : null}
|
|
{debugEnabled ? (
|
|
<div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}>
|
|
<div>Candidates: {candidatesCount}</div>
|
|
{selectedHoraire ? <pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}>{JSON.stringify(selectedHoraire, null, 2)}</pre> : null}
|
|
</div>
|
|
) : null}
|
|
{/* logo du type en haut à droite */}
|
|
<div className="aff-quais__row aff-quais__row--time-status">
|
|
{(() => {
|
|
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
|
|
const status = horaire ? (horaire.status || (horaire._status && horaire._status.label) || mock.status) : mock.status;
|
|
const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time;
|
|
const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim');
|
|
if (isAlert) return displayTimeOrStatus();
|
|
return (
|
|
<>
|
|
<span className="aff-quais__time aff-quais__time--strict">{time}</span>
|
|
<span className="aff-quais__time-status aff-quais__time-status--strict">{status}</span>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="aff-quais__destination aff-quais__destination--strict">
|
|
{(() => {
|
|
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
|
|
const stop = selectedHoraire && selectedHoraire.stop ? selectedHoraire.stop : null;
|
|
// For arrivals: show the origin of the train (gare_depart_*) as the main station label
|
|
const destinationBig = horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || '') : mock.destinationBig;
|
|
const rawDestinationStation = stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation);
|
|
return (
|
|
<>
|
|
<div className="aff-quais__destination-line1 aff-quais__destination-line1--strict">{destinationBig}</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="aff-quais__train-id aff-quais__train-id--strict">{(() => {
|
|
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
|
|
const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;
|
|
const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber;
|
|
return (<>{trainType} <span className="aff-quais__train-number--strict">{trainNumber}</span></>);
|
|
})()}</div>
|
|
</div>
|
|
<div className="aff-quais__separator--strict" aria-hidden></div>
|
|
<div className="aff-quais__right aff-quais__right--strict" ref={rightRef}>
|
|
{/* barre verticale de points (élément explicite pour fiabilité) */}
|
|
<div className="aff-quais__stops-rail" aria-hidden ref={railRef} />
|
|
<ul className="aff-quais__stops-list--strict" ref={stopsListRef}>
|
|
{(() => {
|
|
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
|
|
// utiliser la helper getStops pour supporter plusieurs formats
|
|
let stops = horaire ? getStops(horaire) : [];
|
|
if (!Array.isArray(stops) || stops.length === 0) {
|
|
// fallback parsing si la colonne est une chaîne JSON
|
|
if (horaire && typeof horaire.stops === 'string') {
|
|
try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || [];
|
|
|
|
// construire une liste propre en extrayant un label pour chaque stop
|
|
const labeled = stops.map((stop, idx) => {
|
|
let label = '';
|
|
if (typeof stop === 'string') label = stop;
|
|
else if (stop && typeof stop === 'object') {
|
|
// vérifier plusieurs clés possibles
|
|
label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || '';
|
|
// si l'objet possède une clé 'stop' qui est elle-même un objet
|
|
if (!label && stop.stop && typeof stop.stop === 'object') {
|
|
label = stop.stop.name || stop.stop.stop_name || '';
|
|
}
|
|
}
|
|
label = String(label || '').trim();
|
|
return { label, original: stop, idx };
|
|
}).filter(it => it.label);
|
|
|
|
if (labeled.length === 0) return null;
|
|
|
|
// trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop)
|
|
let matchedIndex = -1;
|
|
try {
|
|
if (selectedHoraire && selectedHoraire.stop) {
|
|
const target = selectedHoraire.stop;
|
|
const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || ''));
|
|
const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null;
|
|
for (let i = 0; i < stops.length; i++) {
|
|
const s = stops[i];
|
|
// compare by id first if available
|
|
if (targetId != null) {
|
|
const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null;
|
|
if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; }
|
|
}
|
|
const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || '');
|
|
if (name && targetName && name === targetName) { matchedIndex = i; break; }
|
|
}
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
|
|
// For arrivals: show the stops served BEFORE the display station (i.e. previous stops)
|
|
// If we have a matchedIndex, keep only items with idx < matchedIndex.
|
|
// Display stops in the reverse direction relative to the route: show the farthest previous stop first
|
|
let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx < matchedIndex) : true);
|
|
// sort ascending so the oldest (farthest) previous stop appears first -> reverse of travel direction
|
|
subsequent = subsequent.sort((a,b) => a.idx - b.idx);
|
|
// Remove consecutive duplicate labels (case-insensitive) to avoid repeated identical station names
|
|
if (subsequent.length > 1) {
|
|
const collapsed = [];
|
|
for (const it of subsequent) {
|
|
const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null;
|
|
const cur = it.label ? String(it.label).trim().toLowerCase() : '';
|
|
if (cur && prev && prev === cur) {
|
|
// skip duplicate consecutive entry
|
|
continue;
|
|
}
|
|
collapsed.push(it);
|
|
}
|
|
subsequent = collapsed;
|
|
}
|
|
// si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue
|
|
if (subsequent.length === 0) return null;
|
|
|
|
return subsequent.map((item, mapIndex) => {
|
|
const isLast = item.idx === (stops.length - 1);
|
|
return (
|
|
<li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}>
|
|
<span className="aff-quais__stop-bullet--strict" />
|
|
<span className="aff-quais__stop-text--strict">{item.label}</span>
|
|
</li>
|
|
);
|
|
});
|
|
})()}
|
|
</ul>
|
|
</div>
|
|
{/* footer band same as classic departures with clock on the right */}
|
|
<div className="aff-quais__footer" aria-hidden>
|
|
<div className="aff-quais__footer-inner">
|
|
<div className="aff-quais__footer-left"></div>
|
|
<div className="aff-quais__footer-right"><Clock /></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|