diff --git a/public/sw.js b/public/sw.js index 847f370..f44546a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,18 +1,6 @@ -const CACHE_NAME = 'ferrovia-pwa-v1'; +const CACHE_NAME = 'ferrovia-pwa-v2'; self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll([ - './', - './index.html', - './manifest.json', - './favicon.ico', - './logo192.png', - './logo512.png' - ]); - }) - ); self.skipWaiting(); }); @@ -28,24 +16,61 @@ self.addEventListener('activate', (event) => { }); self.addEventListener('fetch', (event) => { - // Ignorer les requêtes non-GET et les requêtes vers d'autres origines ou APIs if (event.request.method !== 'GET' || !event.request.url.startsWith(self.location.origin)) { return; } + // Pour la navigation (HTML / pages), stratégie Network-First systématique pour toujours charger la dernière version + const isNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') && event.request.headers.get('accept').includes('text/html')); + + if (isNavigation) { + event.respondWith( + fetch(event.request) + .then((networkResponse) => { + if (networkResponse && networkResponse.ok) { + const clone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return networkResponse; + }) + .catch(() => { + return caches.match(event.request).then((cached) => cached || caches.match('/index.html') || caches.match('./index.html')); + }) + ); + return; + } + + // Pour les scripts JS et CSS, Network-First pour éviter le gel des bundles + const isCodeAsset = event.request.url.includes('/static/js/') || event.request.url.includes('/static/css/'); + if (isCodeAsset) { + event.respondWith( + fetch(event.request) + .then((networkResponse) => { + if (networkResponse && networkResponse.ok) { + const clone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return networkResponse; + }) + .catch(() => { + return caches.match(event.request); + }) + ); + return; + } + + // Pour les autres ressources (images, favicons, polices), Cache-First avec mise en cache réseau event.respondWith( caches.match(event.request).then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(event.request).then((networkResponse) => { - return caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, networkResponse.clone()); - return networkResponse; - }); - }).catch(() => { - // En cas d'échec réseau total, on pourrait retourner un fallback hors-ligne - return caches.match('./index.html'); + if (networkResponse && networkResponse.ok) { + const clone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return networkResponse; }); }) ); diff --git a/src/index.js b/src/index.js index c60d81b..beb243a 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').then(registration => { console.log('SW registered: ', registration); + registration.update(); }).catch(registrationError => { console.log('SW registration failed: ', registrationError); }); diff --git a/src/pages/afficheurs/afl/AFLArrivees.js b/src/pages/afficheurs/afl/AFLArrivees.js index 04a8c08..8d9ecdd 100644 --- a/src/pages/afficheurs/afl/AFLArrivees.js +++ b/src/pages/afficheurs/afl/AFLArrivees.js @@ -55,6 +55,215 @@ const BusIcon = ({ size = '59px', color = 'white', style = {} }) => ( ); +// Icône Débarquement (Arrivées AFL) +const AFLDebarquementIcon = ({ size = '49px', color = 'white', style = {} }) => ( + + {/* Voiture de train / Porte à gauche */} + + + + {/* Silhouette du passager descendant du train vers le quai (tourné vers la droite) */} + + + + +); + +// Pastilles de modification de parcours +const AFLRouteBadge = ({ type = 'modified', size = '52px', style = {} }) => { + if (type === 'cancelled' || type === 'suppressed') { + return ( + + + + + ); + } + if (type === 'added') { + return ( + + + + + ); + } + return ( + + + + + + ); +}; + +// Helper détection des modifications de parcours +const detectRouteModification = (horaire, allPerturbs = [], isDeparts = false, normalizeName = (s) => s) => { + if (!horaire) return { destModif: null, perturbStopsMap: new Map() }; + + const trainNo = String(horaire.numero_train || horaire.number || horaire.numero || '').trim(); + const horaireId = String(horaire.id || '').trim(); + + const matchingPerturbs = (allPerturbs || []).filter(p => { + if (!p) return false; + const pRef = String(p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero || '').trim(); + const pNum = String(p.number || p.train_number || p.numero || p.numero_train || '').trim(); + if (horaireId && pRef === horaireId) return true; + if (trainNo && (pRef === trainNo || pNum === trainNo)) return true; + return false; + }); + + const perturbStopsMap = new Map(); + let explicitNewDest = null; + let explicitOldDest = null; + let explicitNewOrigin = null; + let explicitOldOrigin = null; + + matchingPerturbs.forEach(p => { + if (p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee) { + explicitNewDest = p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee; + } + if (p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale) { + explicitOldDest = p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale; + } + if (p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee) { + explicitNewOrigin = p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee; + } + if (p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale) { + explicitOldOrigin = p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale; + } + + const rawArrets = p.perturbations_arrets || p.arrets || p.arrets_perturbations; + let arretsList = []; + if (Array.isArray(rawArrets)) { + arretsList = rawArrets; + } else if (rawArrets && typeof rawArrets === 'object' && Array.isArray(rawArrets.arrets)) { + arretsList = rawArrets.arrets; + } else if (typeof rawArrets === 'string') { + try { + const parsed = JSON.parse(rawArrets); + arretsList = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.arrets) ? parsed.arrets : []); + } catch (e) {} + } + + arretsList.forEach(a => { + const aName = a.gare_nom || a.nom || a.name || a.station_name || (typeof a === 'string' ? a : ''); + if (!aName) return; + const aType = String(a.type_perturbation || a.type || a.status || '').toLowerCase(); + const norm = normalizeName(aName); + perturbStopsMap.set(norm, { + name: aName, + type: aType, + isSupprime: aType.includes('suppr') || aType.includes('annul') || aType.includes('canc'), + isAjoute: aType.includes('ajout') || aType.includes('add') || aType.includes('nouv'), + isModifie: aType.includes('modif') + }); + }); + }); + + if (horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination) { + explicitOldDest = horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination; + } + if (horaire.nouvelle_destination || horaire.nouveau_terminus || (horaire.destination_modifiee && horaire.gare_arrivee_nom)) { + explicitNewDest = horaire.nouvelle_destination || horaire.nouveau_terminus || horaire.gare_arrivee_nom; + } + if (horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine) { + explicitOldOrigin = horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine; + } + if (horaire.nouvelle_origine || (horaire.origine_modifiee && horaire.gare_depart_nom)) { + explicitNewOrigin = horaire.nouvelle_origine || horaire.gare_depart_nom; + } + + let destModif = null; + + if (isDeparts) { + const currentDest = horaire.gare_arrivee_nom || horaire.destination || ''; + const normDest = normalizeName(currentDest); + const destInPerturb = perturbStopsMap.get(normDest); + + if (explicitNewDest && explicitOldDest && normalizeName(explicitNewDest) !== normalizeName(explicitOldDest)) { + destModif = { + newStation: explicitNewDest, + oldStation: explicitOldDest, + isAdded: Boolean(destInPerturb?.isAjoute || /ajout/.test(String(horaire.type_modification || ''))) + }; + } else if (destInPerturb?.isSupprime) { + destModif = { + newStation: explicitNewDest || 'Terminus modifié', + oldStation: currentDest, + isAdded: false + }; + } else if (destInPerturb?.isAjoute) { + destModif = { + newStation: currentDest, + oldStation: explicitOldDest || 'Destination modifiée', + isAdded: true + }; + } else if (explicitOldDest && normalizeName(explicitOldDest) !== normDest) { + destModif = { + newStation: currentDest, + oldStation: explicitOldDest, + isAdded: Boolean(horaire.destination_ajoutee) + }; + } + } else { + const currentOrig = horaire.gare_depart_nom || horaire.origin || ''; + const normOrig = normalizeName(currentOrig); + const origInPerturb = perturbStopsMap.get(normOrig); + + if (explicitNewOrigin && explicitOldOrigin && normalizeName(explicitNewOrigin) !== normalizeName(explicitOldOrigin)) { + destModif = { + newStation: explicitNewOrigin, + oldStation: explicitOldOrigin, + isAdded: Boolean(origInPerturb?.isAjoute) + }; + } else if (origInPerturb?.isSupprime) { + destModif = { + newStation: explicitNewOrigin || 'Origine modifiée', + oldStation: currentOrig, + isAdded: false + }; + } else if (origInPerturb?.isAjoute) { + destModif = { + newStation: currentOrig, + oldStation: explicitOldOrigin || 'Origine initiale', + isAdded: true + }; + } else if (explicitOldOrigin && normalizeName(explicitOldOrigin) !== normOrig) { + destModif = { + newStation: currentOrig, + oldStation: explicitOldOrigin, + isAdded: Boolean(horaire.origine_ajoutee) + }; + } + } + + return { destModif, perturbStopsMap }; +}; + const ViasMarquee = ({ viaNames }) => { const containerRef = useRef(null); const textRef = useRef(null); @@ -68,7 +277,6 @@ const ViasMarquee = ({ viaNames }) => { const tWidth = textRef.current.scrollWidth; if (tWidth > cWidth) { setIsOverflowing(true); - // Vitesse de défilement rapide (~280px/s) setDuration(Math.max(1.0, tWidth / 280)); } else { setIsOverflowing(false); @@ -99,18 +307,38 @@ const ViasMarquee = ({ viaNames }) => { animationDuration: isOverflowing ? `${duration}s` : undefined }} > - {viaNames.map((v, i) => ( - - {i > 0 && >} - {v} - - ))} + {viaNames.map((item, i) => { + const name = typeof item === 'string' ? item : (item.name || item.gare_nom || ''); + const isSupprime = typeof item === 'object' && Boolean(item.isSupprime); + const isAjoute = typeof item === 'object' && Boolean(item.isAjoute); + const isModifie = typeof item === 'object' && Boolean(item.isModifie); + + return ( + + {i > 0 && >} + + {isSupprime && } + {isAjoute && } + {isModifie && } + {name} + + + ); + })} ); }; + function Clock(){ const [now, setNow] = useState(new Date()); useEffect(() => { @@ -629,7 +857,12 @@ export default function AFLArrivees(){ } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; + if (maxDelay != null) { + const dH = Math.floor(maxDelay / 60); + const dRem = maxDelay % 60; + const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`; + return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay }; + } return { label: "à l'heure", detail: '', delayMinutes: null }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); @@ -1137,7 +1370,7 @@ export default function AFLArrivees(){ const intervalId = setInterval(fetchData, intervalMs); return () => { mounted = false; clearInterval(intervalId); }; - }, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]); + }, [gareDisplay, gareParam, gareIdParam]); // Helpers pour perturbations (memoized plus haut) @@ -1249,16 +1482,7 @@ export default function AFLArrivees(){ {/* HEADER */}
- - {/* Porte de train verticale */} - - {/* Silhouette tête */} - - {/* Silhouette corps + marche vers la droite */} - - {/* Chevrons >> */} - - +
Arrivées
@@ -1326,20 +1550,34 @@ export default function AFLArrivees(){ const pillClass = isSuppressed ? 'afl-arr-pill--suppressed' : isDelayed ? 'afl-arr-pill--delayed' : 'afl-arr-pill--ontime'; let delayVal = '+ 5 min'; + const formatDelayVal = (m) => { + if (m >= 60) { + const h = Math.floor(m / 60); + const rem = m % 60; + return rem > 0 ? `+ ${h}h${String(rem).padStart(2, '0')}` : `+ ${h}h`; + } + return `+ ${m} min`; + }; if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { - delayVal = `+ ${status.delayMinutes} min`; + delayVal = formatDelayVal(status.delayMinutes); } else if (status?.delayMinutes) { - delayVal = `+ ${status.delayMinutes} min`; + const parsed = parseInt(status.delayMinutes, 10); + delayVal = !isNaN(parsed) && parsed > 0 ? formatDelayVal(parsed) : `+ ${status.delayMinutes} min`; } else if (status?.label && status.label.includes('+')) { - delayVal = status.label; + const m = status.label.match(/\+(\d+)\s*(?:min)?/); + if (m) delayVal = formatDelayVal(parseInt(m[1], 10)); + else delayVal = status.label; } let delayDisplayText = alternateDelay ? 'retardé' : delayVal; let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure'; - // Calcul des vias (gares entre l'origine et la gare d'arrivée) + // Détection des modifications de parcours (Origine modifiée) + const { destModif: originModif, perturbStopsMap } = detectRouteModification(horaire, perturbations, false, normalizeName); + + // Calcul des gares de provenance (gares desservies avant la gare affichée dans l'ordre décroissant) const stopsList = getStops(horaire); - const viaNames = []; + const rawPrevStops = []; if (Array.isArray(stopsList)) { for (const s of stopsList) { const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; @@ -1347,9 +1585,26 @@ export default function AFLArrivees(){ const norm = normalizeName(sName); if (norm === normalizeName(origin || '')) continue; if (norm === normalizeName(stationName || '')) break; - viaNames.push(sName); + rawPrevStops.push(s); } } + // Ordre décroissant : la gare la plus proche avant l'arrivée en premier + rawPrevStops.reverse(); + + const viaItems = rawPrevStops.map(s => { + const sName = s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : ''); + const norm = normalizeName(sName); + const pInfo = perturbStopsMap.get(norm); + const isSupprime = Boolean(pInfo?.isSupprime || s.is_supprime || s.supprime || s.type === 'arret_supprime'); + const isAjoute = Boolean(pInfo?.isAjoute || s.is_ajoute || s.ajoute || s.type === 'arret_ajoute'); + const isModifie = Boolean(pInfo?.isModifie || s.is_modifie || s.modifie); + return { + name: sName, + isSupprime, + isAjoute, + isModifie + }; + }); const renderPillIcon = () => { if (isSuppressed) { @@ -1432,16 +1687,44 @@ export default function AFLArrivees(){ <>
{isCarTER && } - + {originModif ? ( + alternateDelay ? ( +
+ + +
+ ) : ( +
+ + +
+ ) + ) : ( + + )}
- +
) : (
{isCarTER && } - + {originModif ? ( + alternateDelay ? ( +
+ + +
+ ) : ( +
+ + +
+ ) + ) : ( + + )}
)} diff --git a/src/pages/afficheurs/afl/AFLDeparts.js b/src/pages/afficheurs/afl/AFLDeparts.js index 77049b8..dbce894 100644 --- a/src/pages/afficheurs/afl/AFLDeparts.js +++ b/src/pages/afficheurs/afl/AFLDeparts.js @@ -55,6 +55,215 @@ const BusIcon = ({ size = '59px', color = 'white', style = {} }) => ( ); +// Icône Embarquement (Départs AFL) +const AFLEmbarquementIcon = ({ size = '49px', color = 'white', style = {} }) => ( + + {/* Voiture de train / Porte à droite */} + + + + {/* Silhouette du passager montant dans le train (tourné vers la droite) */} + + + + +); + +// Pastilles de modification de parcours +const AFLRouteBadge = ({ type = 'modified', size = '52px', style = {} }) => { + if (type === 'cancelled' || type === 'suppressed') { + return ( + + + + + ); + } + if (type === 'added') { + return ( + + + + + ); + } + return ( + + + + + + ); +}; + +// Helper détection des modifications de parcours +const detectRouteModification = (horaire, allPerturbs = [], isDeparts = true, normalizeName = (s) => s) => { + if (!horaire) return { destModif: null, perturbStopsMap: new Map() }; + + const trainNo = String(horaire.numero_train || horaire.number || horaire.numero || '').trim(); + const horaireId = String(horaire.id || '').trim(); + + const matchingPerturbs = (allPerturbs || []).filter(p => { + if (!p) return false; + const pRef = String(p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero || '').trim(); + const pNum = String(p.number || p.train_number || p.numero || p.numero_train || '').trim(); + if (horaireId && pRef === horaireId) return true; + if (trainNo && (pRef === trainNo || pNum === trainNo)) return true; + return false; + }); + + const perturbStopsMap = new Map(); + let explicitNewDest = null; + let explicitOldDest = null; + let explicitNewOrigin = null; + let explicitOldOrigin = null; + + matchingPerturbs.forEach(p => { + if (p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee) { + explicitNewDest = p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee; + } + if (p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale) { + explicitOldDest = p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale; + } + if (p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee) { + explicitNewOrigin = p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee; + } + if (p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale) { + explicitOldOrigin = p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale; + } + + const rawArrets = p.perturbations_arrets || p.arrets || p.arrets_perturbations; + let arretsList = []; + if (Array.isArray(rawArrets)) { + arretsList = rawArrets; + } else if (rawArrets && typeof rawArrets === 'object' && Array.isArray(rawArrets.arrets)) { + arretsList = rawArrets.arrets; + } else if (typeof rawArrets === 'string') { + try { + const parsed = JSON.parse(rawArrets); + arretsList = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.arrets) ? parsed.arrets : []); + } catch (e) {} + } + + arretsList.forEach(a => { + const aName = a.gare_nom || a.nom || a.name || a.station_name || (typeof a === 'string' ? a : ''); + if (!aName) return; + const aType = String(a.type_perturbation || a.type || a.status || '').toLowerCase(); + const norm = normalizeName(aName); + perturbStopsMap.set(norm, { + name: aName, + type: aType, + isSupprime: aType.includes('suppr') || aType.includes('annul') || aType.includes('canc'), + isAjoute: aType.includes('ajout') || aType.includes('add') || aType.includes('nouv'), + isModifie: aType.includes('modif') + }); + }); + }); + + if (horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination) { + explicitOldDest = horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination; + } + if (horaire.nouvelle_destination || horaire.nouveau_terminus || (horaire.destination_modifiee && horaire.gare_arrivee_nom)) { + explicitNewDest = horaire.nouvelle_destination || horaire.nouveau_terminus || horaire.gare_arrivee_nom; + } + if (horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine) { + explicitOldOrigin = horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine; + } + if (horaire.nouvelle_origine || (horaire.origine_modifiee && horaire.gare_depart_nom)) { + explicitNewOrigin = horaire.nouvelle_origine || horaire.gare_depart_nom; + } + + let destModif = null; + + if (isDeparts) { + const currentDest = horaire.gare_arrivee_nom || horaire.destination || ''; + const normDest = normalizeName(currentDest); + const destInPerturb = perturbStopsMap.get(normDest); + + if (explicitNewDest && explicitOldDest && normalizeName(explicitNewDest) !== normalizeName(explicitOldDest)) { + destModif = { + newStation: explicitNewDest, + oldStation: explicitOldDest, + isAdded: Boolean(destInPerturb?.isAjoute || /ajout/.test(String(horaire.type_modification || ''))) + }; + } else if (destInPerturb?.isSupprime) { + destModif = { + newStation: explicitNewDest || 'Terminus modifié', + oldStation: currentDest, + isAdded: false + }; + } else if (destInPerturb?.isAjoute) { + destModif = { + newStation: currentDest, + oldStation: explicitOldDest || 'Destination modifiée', + isAdded: true + }; + } else if (explicitOldDest && normalizeName(explicitOldDest) !== normDest) { + destModif = { + newStation: currentDest, + oldStation: explicitOldDest, + isAdded: Boolean(horaire.destination_ajoutee) + }; + } + } else { + const currentOrig = horaire.gare_depart_nom || horaire.origin || ''; + const normOrig = normalizeName(currentOrig); + const origInPerturb = perturbStopsMap.get(normOrig); + + if (explicitNewOrigin && explicitOldOrigin && normalizeName(explicitNewOrigin) !== normalizeName(explicitOldOrigin)) { + destModif = { + newStation: explicitNewOrigin, + oldStation: explicitOldOrigin, + isAdded: Boolean(origInPerturb?.isAjoute) + }; + } else if (origInPerturb?.isSupprime) { + destModif = { + newStation: explicitNewOrigin || 'Origine modifiée', + oldStation: currentOrig, + isAdded: false + }; + } else if (origInPerturb?.isAjoute) { + destModif = { + newStation: currentOrig, + oldStation: explicitOldOrigin || 'Origine initiale', + isAdded: true + }; + } else if (explicitOldOrigin && normalizeName(explicitOldOrigin) !== normOrig) { + destModif = { + newStation: currentOrig, + oldStation: explicitOldOrigin, + isAdded: Boolean(horaire.origine_ajoutee) + }; + } + } + + return { destModif, perturbStopsMap }; +}; + const ViasMarquee = ({ viaNames }) => { const containerRef = useRef(null); const textRef = useRef(null); @@ -68,7 +277,6 @@ const ViasMarquee = ({ viaNames }) => { const tWidth = textRef.current.scrollWidth; if (tWidth > cWidth) { setIsOverflowing(true); - // Vitesse de défilement multipliée par 2 (~280px/s) setDuration(Math.max(1.0, tWidth / 280)); } else { setIsOverflowing(false); @@ -99,18 +307,38 @@ const ViasMarquee = ({ viaNames }) => { animationDuration: isOverflowing ? `${duration}s` : undefined }} > - {viaNames.map((v, i) => ( - - {i > 0 && >} - {v} - - ))} + {viaNames.map((item, i) => { + const name = typeof item === 'string' ? item : (item.name || item.gare_nom || ''); + const isSupprime = typeof item === 'object' && Boolean(item.isSupprime); + const isAjoute = typeof item === 'object' && Boolean(item.isAjoute); + const isModifie = typeof item === 'object' && Boolean(item.isModifie); + + return ( + + {i > 0 && >} + + {isSupprime && } + {isAjoute && } + {isModifie && } + {name} + + + ); + })} ); }; + // (La fonction TrainType a été retirée car la colonne affiche désormais directement les // informations via .train-meta dans la colonne .col-train-type) @@ -637,7 +865,12 @@ export default function AFLDeparts(){ } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; + if (maxDelay != null) { + const dH = Math.floor(maxDelay / 60); + const dRem = maxDelay % 60; + const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`; + return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay }; + } return { label: "à l'heure", detail: '', delayMinutes: null }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); @@ -1164,7 +1397,7 @@ export default function AFLDeparts(){ const intervalId = setInterval(fetchData, intervalMs); return () => { mounted = false; clearInterval(intervalId); }; - }, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]); + }, [gareDisplay, gareParam, gareIdParam]); // Helpers pour perturbations (memoized plus haut) @@ -1276,16 +1509,7 @@ export default function AFLDeparts(){ {/* HEADER */}
- - {/* Chevrons >> */} - - {/* Silhouette tête */} - - {/* Silhouette corps + marche */} - - {/* Porte de train verticale */} - - +
Départs
@@ -1353,20 +1577,34 @@ export default function AFLDeparts(){ const pillClass = isSuppressed ? 'afl-pill--suppressed' : isDelayed ? 'afl-pill--delayed' : 'afl-pill--ontime'; let delayVal = '+ 5 min'; + const formatDelayVal = (m) => { + if (m >= 60) { + const h = Math.floor(m / 60); + const rem = m % 60; + return rem > 0 ? `+ ${h}h${String(rem).padStart(2, '0')}` : `+ ${h}h`; + } + return `+ ${m} min`; + }; if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { - delayVal = `+ ${status.delayMinutes} min`; + delayVal = formatDelayVal(status.delayMinutes); } else if (status?.delayMinutes) { - delayVal = `+ ${status.delayMinutes} min`; + const parsed = parseInt(status.delayMinutes, 10); + delayVal = !isNaN(parsed) && parsed > 0 ? formatDelayVal(parsed) : `+ ${status.delayMinutes} min`; } else if (status?.label && status.label.includes('+')) { - delayVal = status.label; + const m = status.label.match(/\+(\d+)\s*(?:min)?/); + if (m) delayVal = formatDelayVal(parseInt(m[1], 10)); + else delayVal = status.label; } let delayDisplayText = alternateDelay ? 'retardé' : delayVal; let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure'; + // Détection des modifications de parcours + const { destModif, perturbStopsMap } = detectRouteModification(horaire, perturbations, true, normalizeName); + // Calcul des vias const stopsList = getStops(horaire); - const viaNames = []; + const rawViaStops = []; if (Array.isArray(stopsList) && stopsList.length > 0) { let isAfterStart = false; const hasStartInStops = stopsList.some(s => { @@ -1389,11 +1627,26 @@ export default function AFLDeparts(){ if (norm === normalizeName(destination || '')) { break; } - viaNames.push(sName); + rawViaStops.push(s); } } } + const viaItems = rawViaStops.map(s => { + const sName = s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : ''); + const norm = normalizeName(sName); + const pInfo = perturbStopsMap.get(norm); + const isSupprime = Boolean(pInfo?.isSupprime || s.is_supprime || s.supprime || s.type === 'arret_supprime'); + const isAjoute = Boolean(pInfo?.isAjoute || s.is_ajoute || s.ajoute || s.type === 'arret_ajoute'); + const isModifie = Boolean(pInfo?.isModifie || s.is_modifie || s.modifie); + return { + name: sName, + isSupprime, + isAjoute, + isModifie + }; + }); + const renderPillIcon = () => { if (isSuppressed) { return ( @@ -1475,16 +1728,44 @@ export default function AFLDeparts(){ <>
{isCarTER && } - + {destModif ? ( + alternateDelay ? ( +
+ + +
+ ) : ( +
+ + +
+ ) + ) : ( + + )}
- +
) : (
{isCarTER && } - + {destModif ? ( + alternateDelay ? ( +
+ + +
+ ) : ( +
+ + +
+ ) + ) : ( + + )}
)} diff --git a/src/pages/afficheurs/classiques/ClassiquesArrivees.css b/src/pages/afficheurs/classiques/ClassiquesArrivees.css index 70c827e..8dad2f6 100644 --- a/src/pages/afficheurs/classiques/ClassiquesArrivees.css +++ b/src/pages/afficheurs/classiques/ClassiquesArrivees.css @@ -335,6 +335,7 @@ .aff-classiques-arrivees__col-platform { width: 96px; min-width: 96px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; diff --git a/src/pages/afficheurs/classiques/ClassiquesArrivees.js b/src/pages/afficheurs/classiques/ClassiquesArrivees.js index 31b4080..d544838 100644 --- a/src/pages/afficheurs/classiques/ClassiquesArrivees.js +++ b/src/pages/afficheurs/classiques/ClassiquesArrivees.js @@ -630,7 +630,12 @@ export default function ClassiquesArrivees({ defilement = null } = {}){ } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; + if (maxDelay != null) { + const dH = Math.floor(maxDelay / 60); + const dRem = maxDelay % 60; + const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`; + return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails }; + } return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); @@ -1179,7 +1184,14 @@ export default function ClassiquesArrivees({ defilement = null } = {}){ const intervalMs = 15000; // 15 secondes par défaut const intervalId = setInterval(fetchData, intervalMs); - const onRtUpdate = () => { if (mounted) fetchData(); }; + let rtDebounce = null; + const onRtUpdate = () => { + if (!mounted) return; + clearTimeout(rtDebounce); + rtDebounce = setTimeout(() => { + if (mounted) fetchData(); + }, 2000); + }; if (typeof window !== 'undefined') { window.addEventListener('sncf-realtime-updated', onRtUpdate); } @@ -1187,11 +1199,12 @@ export default function ClassiquesArrivees({ defilement = null } = {}){ return () => { mounted = false; clearInterval(intervalId); + clearTimeout(rtDebounce); if (typeof window !== 'undefined') { window.removeEventListener('sncf-realtime-updated', onRtUpdate); } }; - }, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours, defilementMode]); + }, [gareDisplay, gareParam, gareIdParam, defilementMode]); // Helpers pour perturbations (memoized plus haut) diff --git a/src/pages/afficheurs/classiques/ClassiquesDeparts.css b/src/pages/afficheurs/classiques/ClassiquesDeparts.css index a31e432..d6b64f0 100644 --- a/src/pages/afficheurs/classiques/ClassiquesDeparts.css +++ b/src/pages/afficheurs/classiques/ClassiquesDeparts.css @@ -335,6 +335,7 @@ .aff-classiques-departs__col-platform { width: 96px; min-width: 96px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; diff --git a/src/pages/afficheurs/classiques/ClassiquesDeparts.js b/src/pages/afficheurs/classiques/ClassiquesDeparts.js index aac22c6..9b33fa3 100644 --- a/src/pages/afficheurs/classiques/ClassiquesDeparts.js +++ b/src/pages/afficheurs/classiques/ClassiquesDeparts.js @@ -630,7 +630,12 @@ export default function ClassiquesDeparts({ defilement = null } = {}){ } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; + if (maxDelay != null) { + const dH = Math.floor(maxDelay / 60); + const dRem = maxDelay % 60; + const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`; + return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails }; + } return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); @@ -1178,7 +1183,14 @@ export default function ClassiquesDeparts({ defilement = null } = {}){ const intervalMs = 15000; // 15 secondes par défaut const intervalId = setInterval(fetchData, intervalMs); - const onRtUpdate = () => { if (mounted) fetchData(); }; + let rtDebounce = null; + const onRtUpdate = () => { + if (!mounted) return; + clearTimeout(rtDebounce); + rtDebounce = setTimeout(() => { + if (mounted) fetchData(); + }, 2000); + }; if (typeof window !== 'undefined') { window.addEventListener('sncf-realtime-updated', onRtUpdate); } @@ -1186,11 +1198,12 @@ export default function ClassiquesDeparts({ defilement = null } = {}){ return () => { mounted = false; clearInterval(intervalId); + clearTimeout(rtDebounce); if (typeof window !== 'undefined') { window.removeEventListener('sncf-realtime-updated', onRtUpdate); } }; - }, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours, defilementMode]); + }, [gareDisplay, gareParam, gareIdParam, defilementMode]); // Helpers pour perturbations (memoized plus haut) diff --git a/src/pages/afficheurs/eva/EVAArrivees.css b/src/pages/afficheurs/eva/EVAArrivees.css index 059083a..b0588f5 100644 --- a/src/pages/afficheurs/eva/EVAArrivees.css +++ b/src/pages/afficheurs/eva/EVAArrivees.css @@ -185,13 +185,25 @@ color: #1a2b4c !important; } +.eva-arr-status-pill--approaching { + background-color: #d7e1f0 !important; + border: 1.8px solid #7d95b5 !important; + color: #1a2b4c !important; +} + +.eva-arr-status-pill--atplatform { + background-color: #1a2b4c !important; + border: none !important; + color: #ffffff !important; +} + .eva-arr-status-pill--delayed { background-color: #ffa74f !important; color: #000c2f !important; } .eva-arr-status-pill--suppressed { - background-color: #ff5c73 !important; + background-color: #ff4d6d !important; color: #ffffff !important; } @@ -242,20 +254,28 @@ text-overflow: ellipsis !important; } -/* Badge Motif de Perturbation (ex: Embouteillages) */ +/* Badge Motif de Perturbation */ .eva-arr-perturb-badge { display: inline-flex !important; align-items: center !important; gap: 8px !important; - background-color: #ffa74f !important; - color: #000c2f !important; padding: 4px 15px !important; border-radius: 6px !important; - height: 37px !important; + height: 38px !important; box-sizing: border-box !important; flex-shrink: 0 !important; } +.eva-arr-perturb-badge--delayed { + background-color: #ffa74f !important; + color: #000c2f !important; +} + +.eva-arr-perturb-badge--suppressed { + background-color: #d63031 !important; + color: #ffffff !important; +} + .eva-arr-perturb-badge-icon { width: 26px !important; height: 26px !important; @@ -263,12 +283,23 @@ } .eva-arr-perturb-badge-text { - font-size: 24px !important; + font-size: 22px !important; font-weight: 700 !important; - line-height: 1 !important; + line-height: 1.1 !important; white-space: nowrap !important; } +.eva-arr-vias-direct { + font-size: 38px !important; + font-weight: 700 !important; + color: #00c284 !important; + line-height: 1 !important; +} + +.eva-arr-vias-stop--cancelled { + color: #8b9bb4 !important; +} + /* Section Vias */ .eva-arr-vias-container { display: flex !important; diff --git a/src/pages/afficheurs/eva/EVAArrivees.js b/src/pages/afficheurs/eva/EVAArrivees.js index 666d5e2..b53f002 100644 --- a/src/pages/afficheurs/eva/EVAArrivees.js +++ b/src/pages/afficheurs/eva/EVAArrivees.js @@ -178,6 +178,41 @@ const EVACrossIcon = ({ size = '27px', color = 'currentColor', style = {} }) => ); +// Icône information carrée orange (Badge motif retard EVA Image 1) +const EVAPerturbInfoSquare = ({ size = '36px' }) => ( + + + + + + +); + +// Icône triangle rouge alerte (Badge motif suppression EVA Image 1) +const EVAPerturbWarningSquare = ({ size = '36px' }) => ( + + + + + + +); + +// Icône Train barré en rouge (Boîte Quai pour train supprimé EVA Image 1, 2, 3) +const EVACancelledTrainIcon = ({ size = '60px' }) => ( + + + + + + + + + + + +); + // Défilement des gares de passage (Vias) const EVAViasMarquee = ({ viaNames }) => { const containerRef = useRef(null); @@ -206,7 +241,7 @@ const EVAViasMarquee = ({ viaNames }) => { if (!viaNames || viaNames.length === 0) { return (
- Direct + train direct
); } @@ -222,12 +257,24 @@ const EVAViasMarquee = ({ viaNames }) => { animationDuration: isOverflowing ? `${duration}s` : undefined }} > - {viaNames.map((v, i) => ( - - {i > 0 && •} - {v} - - ))} + {viaNames.map((v, i) => { + const isObj = typeof v === 'object' && v !== null; + const name = isObj ? v.nom || v.name : v; + const isCancelled = isObj && Boolean(v.isCancelled || v.cancelled || v.supprime); + return ( + + {i > 0 && •} + {isCancelled ? ( + + {name} + ✕ + + ) : ( + {name} + )} + + ); + })} @@ -809,7 +856,12 @@ export default function EVAArrivees() { } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; + if (maxDelay != null) { + const dH = Math.floor(maxDelay / 60); + const dRem = maxDelay % 60; + const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`; + return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails }; + } return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); @@ -1230,7 +1282,7 @@ export default function EVAArrivees() { fetchData(); const intervalId = setInterval(fetchData, 15000); return () => { mounted = false; clearInterval(intervalId); }; - }, [gareDisplay, gareParam, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]); + }, [gareDisplay, gareParam, gareIdParam]); // Alternance retard / texte toutes les 2s const [alternateDelay, setAlternateDelay] = useState(false); @@ -1309,6 +1361,15 @@ export default function EVAArrivees() { return null; })(); + const formatTrainNumber = (num) => { + if (!num) return ''; + const s = String(num).trim(); + if (/^\d{4,6}$/.test(s)) { + return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + } + return s; + }; + return (
{/* HEADER */} @@ -1316,9 +1377,9 @@ export default function EVAArrivees() {
- Arrivées + ARRIVÉES / - Arrivals From / Ankünfte Aus + ARRIVALS FROM / LLEGADAS DE
@@ -1350,7 +1411,7 @@ export default function EVAArrivees() { horaire.voie || horaire.track || '' - ).toString().trim(); + ).toString().replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); const isSub = (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 isCarTER = (op === 'CAR TER' || op === 'AUTOCAR'); @@ -1367,19 +1428,42 @@ export default function EVAArrivees() { let delayVal = '+10 min'; if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { - delayVal = `+${status.delayMinutes} min`; + delayVal = `+ ${status.delayMinutes} min`; } else if (status?.delayMinutes) { - delayVal = `+${status.delayMinutes} min`; + 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`; + 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'; + const timeHM = horaire._arrHM || 0; + const isAtPlatform = !isSuppressed && !isDelayed && timeHM === currentHM; + const isApproaching = !isSuppressed && !isDelayed && (timeHM === currentHM + 1 || timeHM === currentHM + 2); - // Motif de perturbation (ex: Embouteillages, Travaux, etc.) + let pillText = "à l'heure"; + let pillModifier = 'ontime'; + let showClockIcon = true; + + if (isSuppressed) { + pillText = alternateDelay ? 'Train' : 'supprimé'; + pillModifier = 'suppressed'; + showClockIcon = true; + } else if (isDelayed) { + pillText = alternateDelay ? 'retardé' : delayVal; + pillModifier = 'delayed'; + showClockIcon = true; + } else if (isAtPlatform) { + pillText = 'à quai'; + pillModifier = 'atplatform'; + showClockIcon = false; + } else if (isApproaching) { + pillText = "à l'approche"; + pillModifier = 'approaching'; + showClockIcon = false; + } + + // Motif de perturbation (ex: Défaut alimentation électrique, Train en panne, etc.) const perturbationReason = decodeText(status?.causeDetails || (isDelayed && !status?.causeDetails ? 'Retard' : '')); // Calcul des gares intermédiaires (Vias) @@ -1392,7 +1476,8 @@ export default function EVAArrivees() { const norm = normalizeName(sName); if (norm === normalizeName(origin || '')) continue; if (norm === normalizeName(stationName || '')) break; - viaNames.push(decodeText(sName)); + const isCancelledStop = s.is_cancelled || s.supprime || s.type === 'arret_supprime'; + viaNames.push({ nom: decodeText(sName), isCancelled: isCancelledStop }); } } @@ -1404,10 +1489,11 @@ export default function EVAArrivees() {
{time}
- {isSuppressed ? ( - - ) : ( - + {showClockIcon && ( + )} {pillText}
@@ -1418,10 +1504,13 @@ export default function EVAArrivees() {
{perturbationReason && ( -
- - {perturbationReason} -
+ alternateDelay ? ( +
+ {perturbationReason} +
+ ) : ( + isSuppressed ? : + ) )}
@@ -1429,11 +1518,15 @@ export default function EVAArrivees() { {/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */}
- {isSub || isCarTER ? ( + {isSuppressed ? ( +
+ +
+ ) : (isSub || isCarTER) ? (
- ) : (rawPlatform && !isSuppressed) ? ( + ) : rawPlatform ? (
Voie {rawPlatform} @@ -1442,7 +1535,7 @@ export default function EVAArrivees() {
{op} - {number} + {formatTrainNumber(number)}
diff --git a/src/pages/afficheurs/eva/EVADeparts.js b/src/pages/afficheurs/eva/EVADeparts.js index 54a2f1c..83fc65e 100644 --- a/src/pages/afficheurs/eva/EVADeparts.js +++ b/src/pages/afficheurs/eva/EVADeparts.js @@ -805,7 +805,12 @@ export default function EVADeparts() { } if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; + if (maxDelay != null) { + const dH = Math.floor(maxDelay / 60); + const dRem = maxDelay % 60; + const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`; + return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails }; + } return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); @@ -1243,7 +1248,7 @@ export default function EVADeparts() { fetchData(); const intervalId = setInterval(fetchData, 15000); return () => { mounted = false; clearInterval(intervalId); }; - }, [gareDisplay, gareParam, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]); + }, [gareDisplay, gareParam, gareIdParam]); // Alternance retard / texte toutes les 2s const [alternateDelay, setAlternateDelay] = useState(false); diff --git a/src/services/sncfApi.js b/src/services/sncfApi.js index cf65ba9..49d052d 100644 --- a/src/services/sncfApi.js +++ b/src/services/sncfApi.js @@ -1,27 +1,34 @@ let isRealtimeRefreshing = false; -// Fonctions de persistance du cache temps réel dans sessionStorage +// Fonctions de persistance du cache temps réel dans localStorage et sessionStorage function saveRealtimeCacheToStorage() { - if (typeof window === 'undefined' || !window.sessionStorage) return; + if (typeof window === 'undefined') return; try { if (siriLiteCache.platforms && siriLiteCache.platforms.size > 0) { const arr = Array.from(siriLiteCache.platforms.entries()).slice(0, 4000); - window.sessionStorage.setItem('sncf_siri_platforms', JSON.stringify({ + const payload = JSON.stringify({ ts: siriLiteCache.timestamp, data: arr - })); + }); + if (window.localStorage) { + window.localStorage.setItem('sncf_siri_platforms', payload); + } + if (window.sessionStorage) { + window.sessionStorage.setItem('sncf_siri_platforms', payload); + } } } catch (_) {} } function restoreRealtimeCacheFromStorage() { - if (typeof window === 'undefined' || !window.sessionStorage) return; + if (typeof window === 'undefined') return; try { - const raw = window.sessionStorage.getItem('sncf_siri_platforms'); + const raw = (window.localStorage && window.localStorage.getItem('sncf_siri_platforms')) || + (window.sessionStorage && window.sessionStorage.getItem('sncf_siri_platforms')); if (raw) { const parsed = JSON.parse(raw); - if (parsed && Array.isArray(parsed.data) && (Date.now() - parsed.ts < 900000)) { + if (parsed && Array.isArray(parsed.data) && (Date.now() - parsed.ts < 1800000)) { siriLiteCache.timestamp = parsed.ts; siriLiteCache.platforms = new Map(parsed.data); } @@ -29,32 +36,43 @@ function restoreRealtimeCacheFromStorage() { } catch (_) {} } -export async function triggerRealtimeBackgroundRefresh() { - if (isRealtimeRefreshing) return; - const now = Date.now(); +let lastRealtimeTriggerTime = 0; - const needRt = (now - gtfsRtCache.timestamp > 30000) || gtfsRtCache.updates.size === 0; - const needSiri = (now - siriLiteCache.timestamp > 60000) || siriLiteCache.platforms.size === 0; - const needSx = (now - situationExchangeCache.timestamp > 60000) || situationExchangeCache.platforms.size === 0; +let activeRealtimePromise = null; + +export async function triggerRealtimeBackgroundRefresh(force = false) { + const now = Date.now(); + if (activeRealtimePromise) return activeRealtimePromise; + if (!force && (now - lastRealtimeTriggerTime < 30000)) return; + lastRealtimeTriggerTime = now; + + const needRt = force || (now - gtfsRtCache.timestamp > 30000) || gtfsRtCache.updates.size === 0; + const needSiri = force || (now - siriLiteCache.timestamp > 60000) || siriLiteCache.platforms.size === 0; + const needSx = force || (now - situationExchangeCache.timestamp > 60000) || situationExchangeCache.platforms.size === 0; if (!needRt && !needSiri && !needSx) return; - isRealtimeRefreshing = true; - try { - const promises = []; - if (needRt) promises.push(fetchGtfsRealtimeData()); - if (needSiri) promises.push(fetchSiriLiteData()); - if (needSx) promises.push(fetchSituationExchangeData()); + activeRealtimePromise = (async () => { + isRealtimeRefreshing = true; + try { + const promises = []; + if (needRt) promises.push(fetchGtfsRealtimeData()); + if (needSiri) promises.push(fetchSiriLiteData()); + if (needSx) promises.push(fetchSituationExchangeData()); - await Promise.allSettled(promises); - } catch (_) { - // ignore - } finally { - isRealtimeRefreshing = false; - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('sncf-realtime-updated')); + await Promise.allSettled(promises); + } catch (_) { + // ignore + } finally { + isRealtimeRefreshing = false; + activeRealtimePromise = null; + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('sncf-realtime-updated')); + } } - } + })(); + + return activeRealtimePromise; } /* eslint-disable */ @@ -204,9 +222,124 @@ function extractAllTrainNumbers(journeyXml) { return Array.from(nums); } + +function processJourneyXml(j, platformsMap, updatesMap) { + // 1. Extraction exhaustive des identifiants et numéros de train + const trainNums = extractAllTrainNumbers(j); + + const journeyRefMatch = j.match(/([^<]+)<\/DatedVehicleJourneyRef>/) || + j.match(/([^<]+)<\/VehicleJourneyRef>/) || + j.match(/[\s\S]*?([^<]+)<\/DatedVehicleJourneyRef>/); + const journeyRef = journeyRefMatch ? journeyRefMatch[1].trim() : ''; + + const isCanceledJourney = j.includes('true') || j.includes('cancelled'); + + // 2. Extraire tous les arrêts desservis (RecordedCall et EstimatedCall) + const callMatches = j.match(/<(?:RecordedCall|EstimatedCall)>[\s\S]*?<\/(?:RecordedCall|EstimatedCall)>/g) || []; + for (const call of callMatches) { + // Extraction du code UIC de l'arrêt + const stopRefMatch = call.match(/([^<]+)<\/StopPointRef>/); + let uic = ''; + let uic7 = ''; + if (stopRefMatch) { + const rawRef = stopRefMatch[1]; + const uicDigits = rawRef.match(/\d{7,8}/); + uic = uicDigits ? uicDigits[0] : rawRef.replace(/\D/g, ''); + if (uic.length === 8 && uic.startsWith('87')) { + uic7 = uic.slice(2); + } + } + + const stopNameMatch = call.match(/([^<]+)<\/StopPointName>/); + const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; + + // Extraction des quais (Départ, Arrivée, Prévu ou Réel) + const depPlatMatch = call.match(/<(?:DeparturePlatformName|PlannedDeparturePlatformName)>([^<]+)<\/(?:DeparturePlatformName|PlannedDeparturePlatformName)>/); + const arrPlatMatch = call.match(/<(?:ArrivalPlatformName|PlannedArrivalPlatformName)>([^<]+)<\/(?:ArrivalPlatformName|PlannedArrivalPlatformName)>/); + const quayMatch = call.match(/([^<]+)<\/QuayRef>/); + + let rawPlatform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : (quayMatch ? quayMatch[1] : ''))).trim(); + const platform = rawPlatform.replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); + + // Extraction des heures et calcul du retard + const expDepMatch = call.match(/([^<]+)<\/ExpectedDepartureTime>/); + const aimDepMatch = call.match(/([^<]+)<\/AimedDepartureTime>/); + const expArrMatch = call.match(/([^<]+)<\/ExpectedArrivalTime>/); + const aimArrMatch = call.match(/([^<]+)<\/AimedArrivalTime>/); + + let delayMin = 0; + const expTimeStr = (expDepMatch && expDepMatch[1]) || (expArrMatch && expArrMatch[1]); + const aimTimeStr = (aimDepMatch && aimDepMatch[1]) || (aimArrMatch && aimArrMatch[1]); + + if (expTimeStr && aimTimeStr) { + const expD = new Date(expTimeStr).getTime(); + const aimD = new Date(aimTimeStr).getTime(); + if (!isNaN(expD) && !isNaN(aimD)) { + delayMin = Math.max(0, Math.round((expD - aimD) / 60000)); + } + } + + const isStopCancelled = isCanceledJourney || call.includes('cancelled') || call.includes('cancelled') || call.includes('true'); + + if (platform) { + for (const tNum of trainNums) { + platformsMap.set(tNum, platform); + const ct = cleanNum(tNum); + if (ct) platformsMap.set(ct, platform); + if (uic) { + platformsMap.set(`${tNum}_${uic}`, platform); + platformsMap.set(`${uic}_${tNum}`, platform); + if (ct) { + platformsMap.set(`${ct}_${uic}`, platform); + platformsMap.set(`${uic}_${ct}`, platform); + } + } + if (uic7) { + platformsMap.set(`${tNum}_${uic7}`, platform); + platformsMap.set(`${uic7}_${tNum}`, platform); + if (ct) { + platformsMap.set(`${ct}_${uic7}`, platform); + platformsMap.set(`${uic7}_${ct}`, platform); + } + } + if (stopNameClean) { + platformsMap.set(`${tNum}_${stopNameClean}`, platform); + if (ct) platformsMap.set(`${ct}_${stopNameClean}`, platform); + } + } + if (journeyRef) { + platformsMap.set(journeyRef, platform); + if (uic) { + platformsMap.set(`${journeyRef}_${uic}`, platform); + platformsMap.set(`${uic}_${journeyRef}`, platform); + } + if (stopNameClean) { + platformsMap.set(`${journeyRef}_${stopNameClean}`, platform); + } + } + } + + const info = { + platform: platform, + delayMinutes: delayMin, + isCanceled: isStopCancelled + }; + + for (const tNum of trainNums) { + if (uic) updatesMap.set(`${tNum}_${uic}`, info); + updatesMap.set(tNum, info); + } + if (journeyRef) { + if (uic) updatesMap.set(`${journeyRef}_${uic}`, info); + updatesMap.set(journeyRef, info); + } + } +} + /** * Récupère et décode le flux SIRI Lite SNCF officiel en temps réel (circulations dans les 60 prochaines minutes) * Source : https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-estimated-timetable + * Décodage en streaming pour apparition instantanée des quais (sans attendre les 22 Mo complets) */ async function fetchSiriLiteData() { const now = Date.now(); @@ -214,131 +347,83 @@ async function fetchSiriLiteData() { return siriLiteCache; } - const platformsMap = new Map(); - const updatesMap = new Map(); + // Conserver les quais déjà connus pour enrichir progressivement + const platformsMap = new Map(siriLiteCache.platforms || []); + const updatesMap = new Map(siriLiteCache.updates || []); try { const res = await fetchWithTimeout(SNCF_SIRI_LITE_TIMETABLE, { headers: { 'Accept': 'application/xml, text/xml, */*' } - }, 35000); + }, 45000); if (res && res.ok) { - const xml = await res.text(); - const journeys = xml.split(''); + let lastDispatchTime = Date.now(); + let newPlatformsCount = 0; - for (let i = 1; i < journeys.length; i++) { - const j = journeys[i]; + if (res.body && typeof res.body.getReader === 'function') { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; - // 1. Extraction exhaustive des identifiants et numéros de train - const trainNums = extractAllTrainNumbers(j); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; - const journeyRefMatch = j.match(/([^<]+)<\/DatedVehicleJourneyRef>/) || j.match(/([^<]+)<\/VehicleJourneyRef>/) || j.match(/[\s\S]*?([^<]+)<\/DatedVehicleJourneyRef>/); - const journeyRef = journeyRefMatch ? journeyRefMatch[1].trim() : ''; + buffer += decoder.decode(value, { stream: true }); - const isCanceledJourney = j.includes('true') || j.includes('cancelled'); - - // 2. Extraire tous les arrêts desservis (RecordedCall et EstimatedCall) - const callMatches = j.match(/<(?:RecordedCall|EstimatedCall)>[\s\S]*?<\/(?:RecordedCall|EstimatedCall)>/g) || []; - for (const call of callMatches) { - // Extraction du code UIC de l'arrêt - const stopRefMatch = call.match(/([^<]+)<\/StopPointRef>/); - let uic = ''; - let uic7 = ''; - if (stopRefMatch) { - const rawRef = stopRefMatch[1]; - const uicDigits = rawRef.match(/\d{7,8}/); - uic = uicDigits ? uicDigits[0] : rawRef.replace(/\D/g, ''); - if (uic.length === 8 && uic.startsWith('87')) { - uic7 = uic.slice(2); - } - } - - const stopNameMatch = call.match(/([^<]+)<\/StopPointName>/); - const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; - - // Extraction des quais (Départ, Arrivée, Prévu ou Réel) - const depPlatMatch = call.match(/<(?:DeparturePlatformName|PlannedDeparturePlatformName)>([^<]+)<\/(?:DeparturePlatformName|PlannedDeparturePlatformName)>/); - const arrPlatMatch = call.match(/<(?:ArrivalPlatformName|PlannedArrivalPlatformName)>([^<]+)<\/(?:ArrivalPlatformName|PlannedArrivalPlatformName)>/); - const quayMatch = call.match(/([^<]+)<\/QuayRef>/); - - let rawPlatform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : (quayMatch ? quayMatch[1] : ''))).trim(); - const platform = rawPlatform.replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); - - // Extraction des heures et calcul du retard - const expDepMatch = call.match(/([^<]+)<\/ExpectedDepartureTime>/); - const aimDepMatch = call.match(/([^<]+)<\/AimedDepartureTime>/); - const expArrMatch = call.match(/([^<]+)<\/ExpectedArrivalTime>/); - const aimArrMatch = call.match(/([^<]+)<\/AimedArrivalTime>/); - - let delayMin = 0; - const expTimeStr = (expDepMatch && expDepMatch[1]) || (expArrMatch && expArrMatch[1]); - const aimTimeStr = (aimDepMatch && aimDepMatch[1]) || (aimArrMatch && aimArrMatch[1]); - - if (expTimeStr && aimTimeStr) { - const expD = new Date(expTimeStr).getTime(); - const aimD = new Date(aimTimeStr).getTime(); - if (!isNaN(expD) && !isNaN(aimD)) { - delayMin = Math.max(0, Math.round((expD - aimD) / 60000)); - } - } - - const isStopCancelled = isCanceledJourney || call.includes('cancelled') || call.includes('cancelled') || call.includes('true'); - - if (platform) { - for (const tNum of trainNums) { - if (uic) { - platformsMap.set(`${tNum}_${uic}`, platform); - platformsMap.set(`${uic}_${tNum}`, platform); - } - if (uic7) { - platformsMap.set(`${tNum}_${uic7}`, platform); - platformsMap.set(`${uic7}_${tNum}`, platform); - } - if (stopNameClean) { - platformsMap.set(`${tNum}_${stopNameClean}`, platform); + let idx; + while ((idx = buffer.indexOf('')) !== -1) { + const journeyXml = buffer.slice(0, idx + 26); + buffer = buffer.slice(idx + 26); + const beforeSize = platformsMap.size; + processJourneyXml(journeyXml, platformsMap, updatesMap); + if (platformsMap.size > beforeSize) { + newPlatformsCount += (platformsMap.size - beforeSize); } } - if (journeyRef) { - if (uic) { - platformsMap.set(`${journeyRef}_${uic}`, platform); - platformsMap.set(`${uic}_${journeyRef}`, platform); - } - if (stopNameClean) { - platformsMap.set(`${journeyRef}_${stopNameClean}`, platform); - } + + // Mise à jour continue du cache mémoire et stockage sans provoquer de re-fetch intempestif + const nowTime = Date.now(); + if (newPlatformsCount > 0 && (nowTime - lastDispatchTime > 1500)) { + lastDispatchTime = nowTime; + newPlatformsCount = 0; + siriLiteCache = { + timestamp: nowTime, + platforms: new Map(platformsMap), + updates: new Map(updatesMap) + }; + saveRealtimeCacheToStorage(); } } + } finally { + try { reader.releaseLock(); } catch (_) {} + } + } else { + // Fallback sans Web Streams + const xml = await res.text(); + const journeys = xml.split(''); + for (let i = 1; i < journeys.length; i++) { + processJourneyXml(journeys[i], platformsMap, updatesMap); + } + } - const info = { - platform: platform, - delayMinutes: delayMin, - isCanceled: isStopCancelled - }; - - for (const tNum of trainNums) { - if (uic) updatesMap.set(`${tNum}_${uic}`, info); - updatesMap.set(tNum, info); - } - if (journeyRef) { - if (uic) updatesMap.set(`${journeyRef}_${uic}`, info); - updatesMap.set(journeyRef, info); - } + if (platformsMap.size > 0 || updatesMap.size > 0) { + siriLiteCache = { + timestamp: Date.now(), + platforms: platformsMap, + updates: updatesMap + }; + saveRealtimeCacheToStorage(); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('sncf-realtime-updated')); } } } - - if (platformsMap.size > 0 || updatesMap.size > 0) { - siriLiteCache = { - timestamp: now, - platforms: platformsMap, - updates: updatesMap - }; - saveRealtimeCacheToStorage(); - } } catch (err) { - console.warn('[sncfApi] Notice: SIRI-Lite Estimated Timetable refresh error/timeout:', err); + console.warn('[sncfApi] Notice: SIRI-Lite Estimated Timetable stream refresh:', err); } return siriLiteCache; @@ -669,32 +754,32 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { const publicUrl = (typeof process !== 'undefined' && process.env && process.env.PUBLIC_URL) ? process.env.PUBLIC_URL : ''; const stationUrl = `${publicUrl}/data/stations/${uic}.json`; - // Récupération avec cache mémoire immédiat pour affichage instantané en dev - let data = stationJsonCache.get(String(uic)); - if (!data) { + // Chargement simultané de toutes les données : Horaires + GTFS-RT + SIRI-LITE + const fetchStationJson = async () => { + let cached = stationJsonCache.get(String(uic)); + if (cached) return cached; const jsonRes = await fetch(stationUrl).catch(err => { console.warn(`[sncfApi] Notice: fetch station ${uic} error:`, err); return null; }); if (jsonRes && jsonRes.ok) { - data = await jsonRes.json(); - stationJsonCache.set(String(uic), data); + cached = await jsonRes.json(); + stationJsonCache.set(String(uic), cached); + return cached; } - } + return null; + }; + + const [stationData] = await Promise.all([ + fetchStationJson(), + triggerRealtimeBackgroundRefresh() + ]); + let data = stationData; if (!data) { console.warn(`[sncfApi] Fichier de gare introuvable : ${stationUrl}`); return null; } - - // Utilisation immédiate du cache temps réel & déclenchement non-bloquant en tâche de fond - const rtPromise = triggerRealtimeBackgroundRefresh(); - if (gtfsRtCache.updates.size === 0 && siriLiteCache.platforms.size === 0) { - await Promise.race([ - rtPromise, - new Promise(resolve => setTimeout(resolve, 800)) - ]); - } const rtData = gtfsRtCache || { updates: new Map(), alerts: [] }; const siriData = siriLiteCache || { platforms: new Map(), updates: new Map() }; const sxData = situationExchangeCache || { platforms: new Map(), updates: new Map(), alerts: [] }; @@ -789,6 +874,13 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { (uicClean && cNum && sxData.platforms.get(`${uicClean}_${cNum}`)) || (cNum && cName && sxData.platforms.get(`${cNum}_${cName}`)) || (rawNum && cName && sxData.platforms.get(`${rawNum}_${cName}`)) || + // 3. Fallback sur le numéro de train seul si connu dans SIRI-Lite / Situation Exchange + (cNum && siriData.platforms.get(cNum)) || + (rawNum && siriData.platforms.get(rawNum)) || + (cNum && siriData.updates.get(cNum)?.platform) || + (rawNum && siriData.updates.get(rawNum)?.platform) || + (cNum && sxData.updates.get(cNum)?.platform) || + (rawNum && sxData.updates.get(rawNum)?.platform) || '' ); }; @@ -841,7 +933,13 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { statusLabel = 'supprimé'; etat = 'supprime'; } else if (delayMinutes > 0) { - statusLabel = `retardé +${delayMinutes}min.`; + if (delayMinutes >= 60) { + const h = Math.floor(delayMinutes / 60); + const rem = delayMinutes % 60; + statusLabel = rem > 0 ? `retardé +${h}h${String(rem).padStart(2, '0')}` : `retardé +${h}h`; + } else { + statusLabel = `retardé +${delayMinutes}min.`; + } etat = 'retarde'; } diff --git a/src/utils/gtfsRegionalRanges.js b/src/utils/gtfsRegionalRanges.js index f48fa53..89e6ada 100644 --- a/src/utils/gtfsRegionalRanges.js +++ b/src/utils/gtfsRegionalRanges.js @@ -187,8 +187,8 @@ export function resolveCirculationIdentity(trainNumStr, options = {}) { isSubstitution: true, regionCode: best.region, brand: meta.brand, - typeLabel: 'CAR', - logoFile: 'track-car.svg', + typeLabel: 'CAR TER', + logoFile: 'sncf-voyageurs-logo.png', cssClass: meta.cssClass, color: meta.color };