-> IMPLÉMENTATION DONNÉES GTFS / GTFS-RT / SIRI LTE SNCF -> RESTRUCTURATION DE L'APP. FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT : -> REFONTE DU DESIGN DES AFFICHEURS CLASSIQUES -> AMÉLIORATION DES
1441 lines
59 KiB
JavaScript
1441 lines
59 KiB
JavaScript
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||
import './EVAArrivees.css';
|
||
import supabase from '../../../lib/supabase';
|
||
import { fetchAllRows } from '../../../lib/fetchAllRows';
|
||
import MarqueeText from '../../../components/MarqueeText';
|
||
import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
|
||
import useAutoAnnonce from '../../../hooks/useAutoAnnonce';
|
||
import { getSourceFromPath } from '../../../services/railwayService';
|
||
import { getSncfTimetable } from '../../../services/sncfApi';
|
||
|
||
// Helper utilitaire : format HH:MM avec deux points (ex: 22:39)
|
||
const formatTimeWithColon = (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}`;
|
||
}
|
||
if (typeof v === 'string') {
|
||
const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);
|
||
if (m) {
|
||
const h = parseInt(m[1], 10) % 24;
|
||
return `${String(h).padStart(2, '0')}:${m[2]}`;
|
||
}
|
||
const m2 = v.match(/T(\d{2}):(\d{2})/);
|
||
if (m2) {
|
||
const h = parseInt(m2[1], 10) % 24;
|
||
return `${String(h).padStart(2, '0')}:${m2[2]}`;
|
||
}
|
||
const m3 = v.match(/(\d{1,2})h(\d{2})/i);
|
||
if (m3) {
|
||
const h = parseInt(m3[1], 10) % 24;
|
||
return `${String(h).padStart(2, '0')}:${m3[2]}`;
|
||
}
|
||
return v.slice(0, 5).replace('h', ':');
|
||
}
|
||
return String(v).slice(0, 5).replace('h', ':');
|
||
};
|
||
|
||
// Icône Passager avec valise à roulettes + Face avant de train (Logo En-tête EVA Arrivées)
|
||
const EVAArrivalsHeaderIcon = ({ size = '59px', color = '#00d68f', style = {} }) => (
|
||
<svg
|
||
viewBox="0 0 64 64"
|
||
width={size}
|
||
height={size}
|
||
fill="none"
|
||
style={{ flexShrink: 0, ...style }}
|
||
>
|
||
{/* Voyageur avec valise */}
|
||
<circle cx="16" cy="18" r="3.2" fill={color} />
|
||
{/* Corps et jambes */}
|
||
<path
|
||
d="M12 25h7l3 11-3.5 1.5-1.5-6-2 10-4.5-1.5 2.5-11-2-1.5z"
|
||
fill={color}
|
||
/>
|
||
{/* Bras tirant la valise */}
|
||
<path
|
||
d="M14 26l-7 14"
|
||
stroke={color}
|
||
strokeWidth="2.2"
|
||
strokeLinecap="round"
|
||
/>
|
||
{/* Valise à roulettes */}
|
||
<rect x="3.5" y="38" width="5.5" height="11" rx="1.5" fill={color} />
|
||
<circle cx="4.5" cy="50.5" r="1.2" fill={color} />
|
||
<circle cx="8" cy="50.5" r="1.2" fill={color} />
|
||
|
||
{/* Train de face */}
|
||
<path
|
||
d="M26 18c0-5 3.5-7.5 12-7.5s12 2.5 12 7.5v27c0 2.5-2 4.5-4.5 4.5h-15c-2.5 0-4.5-2-4.5-4.5V18z"
|
||
fill={color}
|
||
/>
|
||
{/* Pare-brise (découpé en bleu nuit) */}
|
||
<rect x="30" y="16" width="16" height="10" rx="2.5" fill="#000824" />
|
||
{/* Feux avant du train */}
|
||
<circle cx="31.5" cy="40.5" r="2.0" fill="#000824" />
|
||
<circle cx="44.5" cy="40.5" r="2.0" fill="#000824" />
|
||
{/* Grille / Ligne basse du train */}
|
||
<rect x="34" y="42" width="8" height="2" rx="1" fill="#000824" />
|
||
{/* Rails au sol */}
|
||
<line x1="2" y1="53" x2="62" y2="53" stroke={color} strokeWidth="2.8" strokeLinecap="round" />
|
||
<line x1="24" y1="57" x2="52" y2="57" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
// Icône de Bus / Autocar SNCF
|
||
const EVABusIcon = ({ size = '65px', color = '#ffffff', style = {} }) => (
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width={size}
|
||
height={size}
|
||
fill={color}
|
||
style={{ flexShrink: 0, ...style }}
|
||
>
|
||
<path d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z" />
|
||
</svg>
|
||
);
|
||
|
||
// Icône Information (Badge motif perturbation)
|
||
const EVAInfoIcon = ({ size = '26px', color = '#000c2f', style = {} }) => (
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width={size}
|
||
height={size}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth="2.4"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{ flexShrink: 0, ...style }}
|
||
>
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="16" x2="12" y2="12" />
|
||
<line x1="12" y1="8" x2="12.01" y2="8" strokeWidth="3.2" />
|
||
</svg>
|
||
);
|
||
|
||
// Icône Information Grande (Bandeau Travaux inférieur)
|
||
const EVABigAlertIcon = ({ size = '63px', color = '#000c2f', style = {} }) => (
|
||
<svg
|
||
viewBox="0 0 48 48"
|
||
width={size}
|
||
height={size}
|
||
fill="none"
|
||
style={{ flexShrink: 0, ...style }}
|
||
>
|
||
{/* Bulle / Cercle avec pointe basse */}
|
||
<path
|
||
d="M24 6C13.5 6 5 14.5 5 25c0 4.5 1.6 8.7 4.3 12L7 42l6-2c3.1 1.9 6.8 3 11 3 10.5 0 19-8.5 19-19S34.5 6 24 6z"
|
||
stroke={color}
|
||
strokeWidth="3.2"
|
||
strokeLinejoin="round"
|
||
/>
|
||
{/* Point 'i' */}
|
||
<circle cx="24" cy="16" r="2.4" fill={color} />
|
||
{/* Barre 'i' */}
|
||
<path d="M22 22h4v12h-4" stroke={color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||
{/* Ligne sous le bandeau */}
|
||
<path d="M4 44h40" stroke={color} strokeWidth="3" strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
// Icône Horloge pour les pastilles de statut
|
||
const EVAClockIcon = ({ size = '27px', color = 'currentColor', style = {} }) => (
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width={size}
|
||
height={size}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth="2.4"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{ flexShrink: 0, ...style }}
|
||
>
|
||
<circle cx="12" cy="12" r="10" />
|
||
<polyline points="12 6 12 12 16 14" />
|
||
</svg>
|
||
);
|
||
|
||
// Icône Suppression (Croix)
|
||
const EVACrossIcon = ({ size = '27px', color = 'currentColor', style = {} }) => (
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width={size}
|
||
height={size}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth="2.8"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{ flexShrink: 0, ...style }}
|
||
>
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="15" y1="9" x2="9" y2="15" />
|
||
<line x1="9" y1="9" x2="15" y2="15" />
|
||
</svg>
|
||
);
|
||
|
||
// Défilement des gares de passage (Vias)
|
||
const EVAViasMarquee = ({ viaNames }) => {
|
||
const containerRef = useRef(null);
|
||
const textRef = useRef(null);
|
||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||
const [duration, setDuration] = useState(6);
|
||
|
||
useEffect(() => {
|
||
const check = () => {
|
||
if (containerRef.current && textRef.current) {
|
||
const cWidth = containerRef.current.clientWidth;
|
||
const tWidth = textRef.current.scrollWidth;
|
||
if (tWidth > cWidth) {
|
||
setIsOverflowing(true);
|
||
setDuration(Math.max(2.0, tWidth / 240));
|
||
} else {
|
||
setIsOverflowing(false);
|
||
}
|
||
}
|
||
};
|
||
check();
|
||
window.addEventListener('resize', check);
|
||
return () => window.removeEventListener('resize', check);
|
||
}, [viaNames]);
|
||
|
||
if (!viaNames || viaNames.length === 0) {
|
||
return (
|
||
<div className="eva-arr-vias-container">
|
||
<span className="eva-arr-vias-stop">Direct</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="eva-arr-vias-container">
|
||
<span className="eva-arr-vias-label">via</span>
|
||
<div className="eva-arr-vias-scroll-wrapper" ref={containerRef}>
|
||
<div
|
||
ref={textRef}
|
||
className={`eva-arr-vias-inner ${isOverflowing ? 'eva-arr-vias-inner--animating' : ''}`}
|
||
style={{
|
||
animationDuration: isOverflowing ? `${duration}s` : undefined
|
||
}}
|
||
>
|
||
{viaNames.map((v, i) => (
|
||
<React.Fragment key={i}>
|
||
{i > 0 && <span className="eva-arr-vias-bullet">•</span>}
|
||
<span className="eva-arr-vias-stop">{v}</span>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Helper de décodage des caractères, entités HTML et URL
|
||
const decodeText = (raw) => {
|
||
if (raw == null) return '';
|
||
let str = String(raw);
|
||
|
||
// 1. Décodage d'URL si présent (ex: %20, %C3%A9...)
|
||
if (str.includes('%')) {
|
||
try { str = decodeURIComponent(str); } catch (e) {}
|
||
}
|
||
|
||
// 2. Traitement des séquences d'échappement retour à la ligne et apostrophes
|
||
str = str.replace(/\\r\\n|\\r|\\n/g, ' ').replace(/\\'/g, "'").replace(/\\"/g, '"');
|
||
|
||
// 3. Décodage des entités HTML avec DOMParser dans le navigateur
|
||
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
|
||
try {
|
||
const doc = new DOMParser().parseFromString(str, 'text/html');
|
||
str = doc.body.textContent || doc.body.innerText || str;
|
||
} catch (e) {}
|
||
}
|
||
|
||
// 4. Décodage manuel de secours pour entités résiduelles
|
||
const htmlEntitiesMap = {
|
||
' ': ' ',
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
''': "'",
|
||
'’': "’",
|
||
'‘': "‘",
|
||
''': "'",
|
||
'«': '«',
|
||
'»': '»',
|
||
'À': 'À', 'Â': 'Â', 'Ä': 'Ä', 'É': 'É', 'È': 'È', 'Ê': 'Ê', 'Ë': 'Ë',
|
||
'Î': 'Î', 'Ï': 'Ï', 'Ô': 'Ô', 'Ö': 'Ö', 'Ù': 'Ù', 'Û': 'Û', 'Ü': 'Ü',
|
||
'Ç': 'Ç',
|
||
'à': 'à', 'â': 'â', 'ä': 'ä', 'é': 'é', 'è': 'è', 'ê': 'ê', 'ë': 'ë',
|
||
'î': 'î', 'ï': 'ï', 'ô': 'ô', 'ö': 'ö', 'ù': 'ù', 'û': 'û', 'ü': 'ü',
|
||
'ç': 'ç'
|
||
};
|
||
str = str.replace(/&[a-zA-Z0-9#x]+;/g, (m) => htmlEntitiesMap[m] || m);
|
||
|
||
// 5. Nettoyage des espaces consécutifs
|
||
return str.replace(/[ \t\r\n]+/g, ' ').trim();
|
||
};
|
||
|
||
// Composant pour le bandeau d'alerte travaux / perturbations avec scroll vertical progressif
|
||
const EVABottomAlert = ({ message }) => {
|
||
const containerRef = useRef(null);
|
||
const contentRef = useRef(null);
|
||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||
const [overflowDistance, setOverflowDistance] = useState(0);
|
||
const [animState, setAnimState] = useState('initial_pause');
|
||
|
||
const decodedText = decodeText(message);
|
||
|
||
useEffect(() => {
|
||
const measure = () => {
|
||
if (containerRef.current && contentRef.current) {
|
||
const cHeight = containerRef.current.clientHeight;
|
||
const sHeight = contentRef.current.scrollHeight;
|
||
if (sHeight > cHeight + 2) {
|
||
setIsOverflowing(true);
|
||
setOverflowDistance(sHeight - cHeight);
|
||
} else {
|
||
setIsOverflowing(false);
|
||
setOverflowDistance(0);
|
||
}
|
||
}
|
||
};
|
||
measure();
|
||
const to = setTimeout(measure, 150);
|
||
window.addEventListener('resize', measure);
|
||
return () => {
|
||
clearTimeout(to);
|
||
window.removeEventListener('resize', measure);
|
||
};
|
||
}, [decodedText]);
|
||
|
||
useEffect(() => {
|
||
if (!isOverflowing || overflowDistance <= 0) {
|
||
setAnimState('initial_pause');
|
||
return;
|
||
}
|
||
|
||
let timer;
|
||
if (animState === 'initial_pause') {
|
||
// 5 secondes d'attente en haut avant de démarrer le défilement
|
||
timer = setTimeout(() => {
|
||
setAnimState('scrolling');
|
||
}, 5000);
|
||
} else if (animState === 'scrolling') {
|
||
// Défilement progressif vers le bas pendant 10 secondes
|
||
timer = setTimeout(() => {
|
||
setAnimState('end_pause');
|
||
}, 10000);
|
||
} else if (animState === 'end_pause') {
|
||
// 5 secondes d'attente en bas avant de recommencer
|
||
timer = setTimeout(() => {
|
||
setAnimState('resetting');
|
||
}, 5000);
|
||
} else if (animState === 'resetting') {
|
||
// Réinitialisation fluide et retour en haut
|
||
timer = setTimeout(() => {
|
||
setAnimState('initial_pause');
|
||
}, 300);
|
||
}
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [animState, isOverflowing, overflowDistance]);
|
||
|
||
if (!decodedText) return null;
|
||
|
||
let contentStyle = {
|
||
transform: 'translateY(0)',
|
||
transition: 'none',
|
||
opacity: 1
|
||
};
|
||
|
||
if (isOverflowing && overflowDistance > 0) {
|
||
if (animState === 'initial_pause') {
|
||
contentStyle = {
|
||
transform: 'translateY(0)',
|
||
transition: 'none',
|
||
opacity: 1
|
||
};
|
||
} else if (animState === 'scrolling') {
|
||
contentStyle = {
|
||
transform: `translateY(-${overflowDistance}px)`,
|
||
transition: 'transform 10s linear',
|
||
opacity: 1
|
||
};
|
||
} else if (animState === 'end_pause') {
|
||
contentStyle = {
|
||
transform: `translateY(-${overflowDistance}px)`,
|
||
transition: 'none',
|
||
opacity: 1
|
||
};
|
||
} else if (animState === 'resetting') {
|
||
contentStyle = {
|
||
transform: 'translateY(0)',
|
||
transition: 'opacity 0.25s ease',
|
||
opacity: 0
|
||
};
|
||
}
|
||
}
|
||
|
||
return (
|
||
<footer className="eva-arr-bottom-alert">
|
||
<div className="eva-arr-bottom-alert-icon-wrapper">
|
||
<EVABigAlertIcon className="eva-arr-bottom-alert-icon" />
|
||
</div>
|
||
<div className="eva-arr-bottom-alert-container" ref={containerRef}>
|
||
<div className="eva-arr-bottom-alert-content" ref={contentRef} style={contentStyle}>
|
||
{decodedText}
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
);
|
||
};
|
||
|
||
// Horloge temps réel EVA (HH:MM en blanc, SS en vert menthe encadrée)
|
||
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="eva-arr-clock-box" aria-hidden>
|
||
<span className="eva-arr-clock-main">{hh}:{mm}</span>
|
||
<span className="eva-arr-clock-seconds">{ss}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function EVAArrivees() {
|
||
// Paramètres d'URL : ?gare=Nom-de-la-gare, ?gare_id=123
|
||
const params = new URLSearchParams(window.location.search);
|
||
const gareParam = params.get('gare');
|
||
const gareIdParam = params.get('gare_id') || params.get('gareId');
|
||
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;
|
||
|
||
// Support pour ?page=1, ?page=2 pour verrouiller l'affichage sur une page spécifique
|
||
const pageParam = params.get('page');
|
||
const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10))
|
||
? Math.max(0, parseInt(pageParam, 10) - 1)
|
||
: null;
|
||
|
||
// Mode debug via ?debug=1
|
||
const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true');
|
||
// eslint-disable-next-line no-unused-vars
|
||
const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]);
|
||
|
||
// État heure courante
|
||
const [currentTime, setCurrentTime] = useState(new Date());
|
||
useEffect(() => {
|
||
const id = setInterval(() => setCurrentTime(new Date()), 1000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
// Données Supabase
|
||
const [horaires, setHoraires] = useState([]);
|
||
const [allHoraires, setAllHoraires] = useState([]);
|
||
const [perturbations, setPerturbations] = useState([]);
|
||
// eslint-disable-next-line no-unused-vars
|
||
const [stationType, setStationType] = useState(null);
|
||
const [travaux, setTravaux] = useState([]);
|
||
const [travauxSubstitutions, setTravauxSubstitutions] = useState([]);
|
||
const [, setLoading] = useState(false);
|
||
const [, setError] = useState(null);
|
||
|
||
const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null);
|
||
useAnnonceSonoreRealtime(resolvedGareId, 'EVAArrivees');
|
||
|
||
// Récupération type de gare
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
const source = getSourceFromPath(window.location.pathname);
|
||
const fetchStationType = async () => {
|
||
if (source === 'sncf') {
|
||
if (mounted) {
|
||
setStationType('ville');
|
||
setResolvedGareId(null);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
let fetchedType = null;
|
||
let fetchedId = gareIdParam || null;
|
||
if (gareIdParam) {
|
||
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1);
|
||
if (!gerr && gdata && gdata.length) {
|
||
fetchedType = gdata[0] && gdata[0].type;
|
||
fetchedId = gdata[0] && gdata[0].id;
|
||
}
|
||
} else if (gareDisplay) {
|
||
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
|
||
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle);
|
||
if (!gerr && 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];
|
||
fetchedType = chosen ? chosen.type : null;
|
||
fetchedId = chosen ? chosen.id : null;
|
||
}
|
||
}
|
||
if (mounted) {
|
||
setStationType(fetchedType ? String(fetchedType).toLowerCase() : null);
|
||
setResolvedGareId(fetchedId);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
fetchStationType();
|
||
return () => { mounted = false; };
|
||
}, [gareIdParam, gareDisplay]);
|
||
|
||
// Helpers pour perturbations
|
||
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) { /* ignore */ }
|
||
}
|
||
}
|
||
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 = perturbations, horairesList = allHoraires) => {
|
||
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(horairesList) && horairesList.length) {
|
||
try {
|
||
const byH = Object.keys(idx.byHoraire || {});
|
||
if (byH && byH.length) {
|
||
for (const hid of byH) {
|
||
const h = horairesList.find(x => String(x.id || x.horaire_id || x.horaireId || x.numero) === 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;
|
||
}, [perturbations, allHoraires, 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;
|
||
let causeDetails = '';
|
||
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
|
||
// Extraction du motif / cause de la perturbation
|
||
const cause = p.cause_details || p.motif || p.cause || p.description || p.information || p.details || '';
|
||
if (cause) {
|
||
causeDetails = cause;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails };
|
||
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails };
|
||
return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails };
|
||
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
|
||
|
||
const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => {
|
||
const map = buildPerturbMap(perturbList || []);
|
||
const targetStationId = displayStationId || gareIdParam || null;
|
||
const targetStationName = displayStationName || gareDisplay || 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 || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || '');
|
||
horaire._stopsStatus = stopsStatus;
|
||
|
||
const stopForStation = (stops || []).find(s => {
|
||
const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
|
||
const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';
|
||
const ns = normalizeName(nm || '');
|
||
const target = normalizeName(targetStationName || '');
|
||
return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target);
|
||
}) || null;
|
||
|
||
horaire._status = displayStatus;
|
||
horaire._displayTime = displayTime;
|
||
const platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || '';
|
||
horaire._displayPlatform = String(platformVal).trim();
|
||
return horaire;
|
||
});
|
||
}, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]);
|
||
|
||
// Récupération des données (SNCF GTFS ou Supabase) avec polling
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
let inFlight = false;
|
||
const source = getSourceFromPath(window.location.pathname);
|
||
|
||
const fetchData = async () => {
|
||
if (!mounted || inFlight) return;
|
||
inFlight = true;
|
||
|
||
if (source === 'sncf') {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const { data: sncfData, error: sncfErr } = await getSncfTimetable({
|
||
gareName: gareDisplay || gareParam,
|
||
mode: 'arrivees'
|
||
});
|
||
if (!mounted) { inFlight = false; return; }
|
||
if (sncfErr) {
|
||
setError(sncfErr);
|
||
} else {
|
||
const formatted = (sncfData || []).map(t => {
|
||
const timeStr = t.heure_arrivee || t.heure_depart || t.time || '';
|
||
return {
|
||
...t,
|
||
_displayTime: formatTimeWithColon(timeStr),
|
||
_displayPlatform: t.quai || t.voie || '',
|
||
_arrHM: t._arrHM || 0,
|
||
_status: t._status || {
|
||
label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"),
|
||
detail: '',
|
||
delayMinutes: t.retard_minutes || null,
|
||
causeDetails: t.cause_retard || null
|
||
}
|
||
};
|
||
});
|
||
setHoraires(formatted.slice(0, 10));
|
||
setAllHoraires(formatted);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) setError(e.message || String(e));
|
||
} finally {
|
||
if (mounted) setLoading(false);
|
||
inFlight = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!supabase) {
|
||
setLoading(false);
|
||
inFlight = false;
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }, { data: travauxSubstData }] = await Promise.all([
|
||
fetchAllRows(supabase.from('horaires').select('*')),
|
||
supabase.from('perturbations').select('*'),
|
||
supabase.from('travaux').select('*'),
|
||
supabase.from('travaux_substitutions').select('*')
|
||
]);
|
||
|
||
if (!mounted) { inFlight = false; return; }
|
||
if (hError || pError || tError) {
|
||
if (mounted) setError((hError || pError || tError).message || String(hError || pError || tError));
|
||
setLoading(false);
|
||
inFlight = false;
|
||
return;
|
||
}
|
||
|
||
let parsedHoraires = (horairesData || []).map(h => h || {});
|
||
parsedHoraires = parsedHoraires.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;
|
||
});
|
||
|
||
if (mounted) setAllHoraires(parsedHoraires || []);
|
||
|
||
let displayHoraires = parsedHoraires.slice();
|
||
if (gareDisplay) {
|
||
const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));
|
||
displayHoraires = displayHoraires.filter(h => {
|
||
const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString());
|
||
if (departName === normalized) return false;
|
||
|
||
const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());
|
||
if (arriveeName === normalized) return true;
|
||
|
||
const stopsList = getStops(h);
|
||
if (Array.isArray(stopsList) && stopsList.length) {
|
||
for (const s of stopsList) {
|
||
const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString());
|
||
if (!stopName) continue;
|
||
const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
|
||
if (isOrigin) continue;
|
||
if (stopName === normalized) return true;
|
||
}
|
||
}
|
||
return false;
|
||
});
|
||
} else if (gareIdParam) {
|
||
const gid = String(gareIdParam);
|
||
displayHoraires = displayHoraires.filter(h => {
|
||
const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;
|
||
if (departId != null && String(departId) === gid) return false;
|
||
|
||
const destId = (h.gare_arrivee_id || h.gare_arrivee || null);
|
||
if (destId != null && String(destId) === gid) return true;
|
||
|
||
const stopsList = getStops(h);
|
||
if (Array.isArray(stopsList) && stopsList.length) {
|
||
for (const s of stopsList) {
|
||
const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
|
||
if (stopId == null) continue;
|
||
const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
|
||
if (isOrigin) continue;
|
||
if (String(stopId) === gid) return true;
|
||
}
|
||
}
|
||
return false;
|
||
});
|
||
}
|
||
|
||
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 = displayHoraires.map(h => ({ ...h, _date: normalizeDate(h) }));
|
||
|
||
const parseTimeHM = (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 now = new Date();
|
||
const todayHM = now.getHours() * 60 + now.getMinutes();
|
||
const enrich = withDates.map(h => {
|
||
let raw = h.heure_arrivee || h.time || h.heure || '';
|
||
const stopsListForH = getStops(h);
|
||
if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) {
|
||
const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;
|
||
const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null;
|
||
const stop = byId || byName;
|
||
if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure;
|
||
}
|
||
const depHM = parseTimeHM(raw);
|
||
const displayTime = formatTimeWithColon(raw);
|
||
return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime };
|
||
});
|
||
|
||
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) => {
|
||
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
const customDaysRaw = h.jours_personnalises;
|
||
if (!customDaysRaw) return false;
|
||
let customDays = [];
|
||
if (Array.isArray(customDaysRaw)) {
|
||
customDays = customDaysRaw;
|
||
} else if (typeof customDaysRaw === 'string') {
|
||
try { customDays = JSON.parse(customDaysRaw); } catch (e) { return false; }
|
||
}
|
||
if (Array.isArray(customDays)) {
|
||
const match = customDays.find(item => item.date === targetDateStr);
|
||
if (match) return Boolean(match.circule);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const travauxList = travauxData || [];
|
||
const travauxSubstList = travauxSubstData || [];
|
||
|
||
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.dateDebut || t.start_date);
|
||
const df = parseDateOnly(t.date_fin || t.dateFin || 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.heureDebut || t.start_time);
|
||
const endHM = parseTimeHM(t.heure_fin || t.heureFin || 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 travauxSubstList.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 || t.ligneId || t.lineId;
|
||
const hLine = h.ligne_id || h.ligne || h.line_id || h.line || h.ligneId || h.lineId;
|
||
|
||
if (typeof tLine !== 'undefined' && tLine !== null) {
|
||
if (typeof hLine === 'undefined' || hLine === null) continue;
|
||
if (String(tLine) !== String(hLine)) continue;
|
||
}
|
||
|
||
const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero;
|
||
if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true;
|
||
|
||
const substForThisTravail = travauxSubstList.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 };
|
||
});
|
||
|
||
const todayCandidates = enriched.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;
|
||
if (h._depHM < todayHM) return false;
|
||
if (h._travauxAppliesToday) return false;
|
||
return true;
|
||
});
|
||
|
||
const tomorrowCandidates = enriched.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 && todayCandidates.length > 0) {
|
||
finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999));
|
||
serviceDateStr = todayStr;
|
||
} else {
|
||
finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999));
|
||
serviceDateStr = tomorrowStr;
|
||
}
|
||
|
||
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;
|
||
try {
|
||
const joursSet = getPerturbationJours(p);
|
||
if (joursSet && !joursSet.has(serviceDateStr)) return false;
|
||
} catch (e) {}
|
||
return true;
|
||
} catch (e) {
|
||
return true;
|
||
}
|
||
});
|
||
|
||
const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr);
|
||
const withServiceDate = (annotated || []).map(a => ({ ...a, _serviceDate: serviceDateStr }));
|
||
|
||
setHoraires(withServiceDate || []);
|
||
setPerturbations(filteredPerturb || []);
|
||
setTravaux(travauxData || []);
|
||
setTravauxSubstitutions(travauxSubstData || []);
|
||
} catch (err) {
|
||
console.error('Erreur Supabase EVAArrivees', err);
|
||
if (mounted) setError(err.message || String(err));
|
||
} finally {
|
||
if (mounted) setLoading(false);
|
||
inFlight = false;
|
||
}
|
||
};
|
||
|
||
fetchData();
|
||
const intervalId = setInterval(fetchData, 15000);
|
||
return () => { mounted = false; clearInterval(intervalId); };
|
||
}, [gareDisplay, gareParam, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]);
|
||
|
||
// Alternance retard / texte toutes les 2s
|
||
const [alternateDelay, setAlternateDelay] = useState(false);
|
||
useEffect(() => {
|
||
const delayTimer = setInterval(() => {
|
||
setAlternateDelay(prev => !prev);
|
||
}, 2000);
|
||
return () => clearInterval(delayTimer);
|
||
}, []);
|
||
|
||
// Filtrage en temps réel : ne plus afficher les horaires dont l'heure est dépassée
|
||
const currentHM = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const todayDateStr = `${currentTime.getFullYear()}-${pad(currentTime.getMonth() + 1)}-${pad(currentTime.getDate())}`;
|
||
|
||
const activeHoraires = (horaires || []).filter(h => {
|
||
const sDate = h._serviceDate || h._date;
|
||
if (!sDate || sDate === todayDateStr) {
|
||
if (typeof h._depHM === 'number') {
|
||
const delay = (h._status && typeof h._status.delayMinutes === 'number') ? h._status.delayMinutes : 0;
|
||
const effectiveHM = h._depHM + delay;
|
||
if (effectiveHM < currentHM) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
|
||
useAutoAnnonce(activeHoraires, currentTime, true);
|
||
|
||
// Pagination : 4 trains par page sur les écrans EVA
|
||
const ITEMS_PER_PAGE = 4;
|
||
const totalPages = Math.max(1, Math.ceil(activeHoraires.length / ITEMS_PER_PAGE));
|
||
const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0);
|
||
|
||
useEffect(() => {
|
||
if (lockedPageIndex !== null) {
|
||
setPageIndex(Math.min(lockedPageIndex, totalPages - 1));
|
||
return;
|
||
}
|
||
const pageInterval = setInterval(() => {
|
||
setPageIndex((prev) => {
|
||
if (totalPages <= 1) return 0;
|
||
return (prev + 1) % totalPages;
|
||
});
|
||
}, 10000); // 10 secondes par page
|
||
return () => clearInterval(pageInterval);
|
||
}, [lockedPageIndex, totalPages]);
|
||
|
||
const safePageIndex = Math.min(pageIndex, Math.max(0, totalPages - 1));
|
||
const displayedHoraires = activeHoraires.slice(safePageIndex * ITEMS_PER_PAGE, (safePageIndex + 1) * ITEMS_PER_PAGE);
|
||
|
||
// Recherche d'un message de travaux ou d'alerte pour le bandeau bas
|
||
const activeTravauxMessage = (() => {
|
||
// Chercher un message explicite dans travaux
|
||
if (Array.isArray(travaux) && travaux.length > 0) {
|
||
const activeT = travaux.find(t => {
|
||
const txt = t.message || t.description || t.texte || t.motif || t.details || t.titre || t.commentaire || '';
|
||
return Boolean(txt && txt.trim().length > 0);
|
||
});
|
||
if (activeT) {
|
||
return activeT.message || activeT.description || activeT.texte || activeT.motif || activeT.details || activeT.titre || activeT.commentaire;
|
||
}
|
||
}
|
||
// Chercher dans perturbations générales
|
||
if (Array.isArray(perturbations) && perturbations.length > 0) {
|
||
const activeP = perturbations.find(p => {
|
||
const txt = p.message || p.description || p.texte || p.cause_details || '';
|
||
return Boolean(txt && txt.trim().length > 0);
|
||
});
|
||
if (activeP) {
|
||
return activeP.message || activeP.description || activeP.texte || activeP.cause_details;
|
||
}
|
||
}
|
||
return null;
|
||
})();
|
||
|
||
return (
|
||
<div className="eva-arr-container">
|
||
{/* HEADER */}
|
||
<header className="eva-arr-header">
|
||
<div className="eva-arr-header-left">
|
||
<EVAArrivalsHeaderIcon className="eva-arr-header-icon" />
|
||
<div className="eva-arr-header-titles">
|
||
<span className="eva-arr-header-title-main">Arrivées</span>
|
||
<span className="eva-arr-header-slash">/</span>
|
||
<span className="eva-arr-header-title-sub">Arrivals From / Ankünfte Aus</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="eva-arr-header-right">
|
||
<Clock />
|
||
</div>
|
||
</header>
|
||
|
||
{/* LISTE DES TRAINS (Cartes) */}
|
||
<ul className={`eva-arr-list ${activeTravauxMessage ? 'eva-arr-list--with-alert' : ''}`}>
|
||
{displayedHoraires.length === 0 ? (
|
||
<li className="eva-arr-row" key="empty">
|
||
<div style={{ color: '#000c2f', padding: '24px', fontSize: '30px', fontWeight: 'bold' }}>
|
||
Aucune arrivée prévue pour le moment.
|
||
</div>
|
||
</li>
|
||
) : (
|
||
displayedHoraires.map((h, idx) => {
|
||
const horaire = h || {};
|
||
const op = (horaire.type_train || horaire.op || horaire.type || 'TER').toUpperCase();
|
||
const number = horaire.numero_train || horaire.number || horaire.numero || '';
|
||
|
||
const rawPlatform = (
|
||
horaire._displayPlatform ||
|
||
horaire.quai_reel ||
|
||
horaire.quai ||
|
||
horaire.quai_prevu ||
|
||
horaire.platform ||
|
||
horaire.voie ||
|
||
horaire.track ||
|
||
''
|
||
).toString().trim();
|
||
|
||
const isCG = rawPlatform.toUpperCase() === 'CG' ||
|
||
rawPlatform.toUpperCase().includes('CG') ||
|
||
String(horaire.quai || '').toUpperCase().includes('CG') ||
|
||
String(horaire.quai_reel || '').toUpperCase().includes('CG') ||
|
||
String(horaire.quai_prevu || '').toUpperCase().includes('CG') ||
|
||
String(horaire.platform || '').toUpperCase().includes('CG');
|
||
|
||
const isCarTER = isCG ||
|
||
op.includes('CAR') ||
|
||
op.includes('AUTOCAR') ||
|
||
op.includes('BUS') ||
|
||
String(horaire.mode || '').toUpperCase().includes('BUS') ||
|
||
String(horaire.mode || '').toUpperCase().includes('CAR') ||
|
||
String(horaire.mode_transport || '').toUpperCase().includes('CAR') ||
|
||
String(horaire.mode_transport || '').toUpperCase().includes('BUS') ||
|
||
String(horaire.type_train || '').toUpperCase().includes('CAR') ||
|
||
String(horaire.type_train || '').toUpperCase().includes('BUS') ||
|
||
String(horaire.libelle_type_train || '').toUpperCase().includes('CAR') ||
|
||
String(horaire.libelle_type_train || '').toUpperCase().includes('BUS') ||
|
||
(travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero));
|
||
|
||
const time = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || '');
|
||
const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue';
|
||
|
||
const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null);
|
||
const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null);
|
||
const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));
|
||
|
||
const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé');
|
||
const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard');
|
||
|
||
let delayVal = '+10 min';
|
||
if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) {
|
||
delayVal = `+${status.delayMinutes} min`;
|
||
} else if (status?.delayMinutes) {
|
||
delayVal = `+${status.delayMinutes} min`;
|
||
} else if (status?.label && status.label.includes('+')) {
|
||
const m = status.label.match(/\+(\d+)\s*(?:min)?/);
|
||
if (m) delayVal = `+${m[1]} min`;
|
||
else delayVal = status.label;
|
||
}
|
||
|
||
let pillText = isSuppressed ? 'supprimé' : isDelayed ? (alternateDelay ? 'retardé' : delayVal) : 'à l\'heure';
|
||
const pillModifier = isSuppressed ? 'suppressed' : isDelayed ? 'delayed' : 'ontime';
|
||
|
||
// Motif de perturbation (ex: Embouteillages, Travaux, etc.)
|
||
const perturbationReason = decodeText(status?.causeDetails || (isDelayed && !status?.causeDetails ? 'Retard' : ''));
|
||
|
||
// Calcul des gares intermédiaires (Vias)
|
||
const stopsList = getStops(horaire);
|
||
const viaNames = [];
|
||
if (Array.isArray(stopsList)) {
|
||
for (const s of stopsList) {
|
||
const sName = s.gare_nom || s.nom || s.name || s.station_name || '';
|
||
if (!sName) continue;
|
||
const norm = normalizeName(sName);
|
||
if (norm === normalizeName(origin || '')) continue;
|
||
if (norm === normalizeName(stationName || '')) break;
|
||
viaNames.push(decodeText(sName));
|
||
}
|
||
}
|
||
|
||
const decodedOrigin = decodeText(origin);
|
||
|
||
return (
|
||
<li key={idx} className="eva-arr-row">
|
||
{/* COLONNE GAUCHE : HEURE & PASTILLE DE STATUT */}
|
||
<div className="eva-arr-col-time">
|
||
<div className="eva-arr-time-value">{time}</div>
|
||
<div className={`eva-arr-status-pill eva-arr-status-pill--${pillModifier}`}>
|
||
{isSuppressed ? (
|
||
<EVACrossIcon className="eva-arr-status-icon" color="#ffffff" />
|
||
) : (
|
||
<EVAClockIcon className="eva-arr-status-icon" color={isDelayed ? '#000c2f' : '#1a2b4c'} />
|
||
)}
|
||
<span className="eva-arr-status-text">{pillText}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* SECTION CENTRALE : PROVENANCE & MOTIF PERTURBATION & VIAS */}
|
||
<div className="eva-arr-col-center">
|
||
<div className="eva-arr-origin-row">
|
||
<MarqueeText text={decodedOrigin} className="eva-arr-origin-name" style={{ flex: 'none' }} />
|
||
{perturbationReason && (
|
||
<div className="eva-arr-perturb-badge">
|
||
<EVAInfoIcon className="eva-arr-perturb-badge-icon" />
|
||
<span className="eva-arr-perturb-badge-text">{perturbationReason}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<EVAViasMarquee viaNames={viaNames} />
|
||
</div>
|
||
|
||
{/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */}
|
||
<div className="eva-arr-col-right">
|
||
{isCarTER ? (
|
||
<div className="eva-arr-bus-box">
|
||
<EVABusIcon className="eva-arr-bus-icon" />
|
||
</div>
|
||
) : (rawPlatform && !isCG) ? (
|
||
<div className="eva-arr-voie-box">
|
||
<span className="eva-arr-voie-label">Voie</span>
|
||
<span className="eva-arr-voie-val">{rawPlatform}</span>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="eva-arr-train-block">
|
||
<span className="eva-arr-train-op">{op}</span>
|
||
<span className="eva-arr-train-num">{number}</span>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})
|
||
)}
|
||
</ul>
|
||
|
||
{/* BANDEAU BAS : INFORMATION TRAVAUX / PERTURBATION AVEC SCROLL PROGRESSIF */}
|
||
{activeTravauxMessage && (
|
||
<EVABottomAlert message={activeTravauxMessage} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|