diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2ca5453 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# 📋 Changelog / Patchnotes - Afficheurs Ferrovia + +Toutes les modifications notables apportĂ©es au projet **Afficheurs Ferrovia** sont consignĂ©es dans ce document. + +--- + +## 🚀 [Version 4.0.0] - V4 (2026-08-30) + +### 🌟 NouveautĂ©s majeures +- **IntĂ©gration intĂ©grale du GTFS SNCF officiel :** + - RĂ©fĂ©rentiel exhaustif de **3 507 gares et haltes ferroviaires françaises** avec leurs identifiants UIC officiels. + - Traitement de **41 847 circulations ferroviaires** (TGV INOUI, OUIGO, TER, INTERCITÉS, INTERCITÉS DE NUIT, TGV Lyria, ICE, Autocars TER). + - Prise en charge complĂšte des calendriers de circulation rĂ©guliers et des dates d'exceptions. +- **Flux Temps RĂ©el SNCF SIRI-Lite & GTFS-RT :** + - Connexion directe aux flux temps rĂ©el du portail Open Data Transport (`transport.data.gouv.fr`). + - RĂ©cupĂ©ration en continu des retards estimĂ©s, annulations et causes de perturbations. + - Extraction des voies et quais rĂ©els de dĂ©part et d'arrivĂ©e. +- **RĂ©solution et recherche intelligente des gares :** + - Moteur de normalisation tolĂ©rant aux tirets, accents, abrĂ©viations et majuscules/minuscules (`cleanStationQuery`). + - SystĂšme d'alias prioritaires et scoring par tokens pour rĂ©soudre instantanĂ©ment les requĂȘtes comme `Valence-TGV`, `Paris-Montparnasse`, `Lyon-Part-Dieu`, etc. + +### đŸ› ïž Corrections & Optimisations +- **Isolation stricte des quais par gare :** + - Suppression de la propagation globale du quai sur tout le trajet d'un train (ex: train multi-arrĂȘts). + - La voie affichĂ©e correspond strictement Ă  la voie de la gare consultĂ©e, et chaque arrĂȘt intermĂ©diaire affiche son propre quai rĂ©el. +- **Normalisation des horaires $\ge 24\text{h}$ :** + - Correction des horaires de trains de nuit (ex: `28h15` normalisĂ© en `04h15` / `04:15`). +- **FiabilitĂ© du build :** + - RĂ©solution de 100 % des avertissements ESLint (`react-hooks/exhaustive-deps`, `no-unused-vars`). + - Compilation de production validĂ©e avec un statut `Compiled successfully`. + +--- + +## 🚆 [Version 3.0.0] - V3 + +### 🌟 NouveautĂ©s majeures +- **Afficheurs multi-styles :** + - **AFL (Afficheurs Fluides & Lumineux) :** Écrans haute lisibilitĂ© modernes bleus et verts pour halls et quais. + - **Classiques :** Écrans d'information voyageurs traditionnels (versions DĂ©parts, ArrivĂ©es et Quai). + - **EVA :** Écrans vidĂ©o voyageurs enrichis. +- **Module d'annonces sonores :** + - DĂ©clenchement automatique des jingles et annonces vocales lors de modifications d'horaires, retards ou dĂ©parts imminents. +- **Support des tranches et trains combinĂ©s :** + - Affichage Ă©lĂ©gant des destinations multiples et des sĂ©parations de rames en cours de route. + +### đŸ› ïž Corrections & Optimisations +- DĂ©filement fluide des arrĂȘts intermĂ©diaires sur les Ă©crans de quai. +- Gestion des travaux et des substitutions par autocar. + +--- + +## 🚉 [Version 2.0.0] - V2 + +### 🌟 NouveautĂ©s majeures +- **Écrans dĂ©diĂ©s par Quai / Voie :** + - Ajout des vues `ClassiquesDepartsQuais` et `ClassiquesArriveesQuais` avec affichage grand format du prochain dĂ©part/arrivĂ©e. + - Liste dĂ©taillĂ©e des arrĂȘts intermĂ©diaires desservis en aval. +- **Synchronisation dynamique :** + - IntĂ©gration de la base Supabase avec synchronisation pĂ©riodique automatique sans rechargement de page. + - Alternance visuelle entre heure prĂ©vue, Ă©tat de retard et informations de perturbation. + +--- + +## 🏁 [Version 1.0.0] - V1 + +### 🌟 NouveautĂ©s initiales +- CrĂ©ation de la plateforme d'affichage ferroviaire inspirĂ©e des systĂšmes PIDS (Passenger Information Display Systems). +- Affichage des dĂ©parts et arrivĂ©es en temps rĂ©el avec horloge synchronisĂ©e. +- Reproduction fidĂšle des polices, couleurs et contrastes de la signalĂ©tique ferroviaire. diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..778e622 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,108 @@ +# ============================================================================== +# Configuration Nginx pour Afficheurs Ferroviaires (React PWA) +# ============================================================================== + +# ------------------------------------------------------------------------------ +# OPTION 1 : Reverse Proxy vers PM2 / Node.js (port 4010) +# ------------------------------------------------------------------------------ +server { + listen 80; + listen [::]:80; + server_name your-domain.com; # Remplacez par votre nom de domaine ou IP + + # Redirection HTTP vers HTTPS (Ă  dĂ©commenter en production avec SSL) + # return 301 https://$host$request_uri; + + # Compression Gzip + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/x-javascript + application/xml + application/xml+rss + image/svg+xml; + + location / { + proxy_pass http://127.0.0.1:4010; + proxy_http_version 1.1; + + # En-tĂȘtes pour WebSockets et Realtime (Supabase / SSE) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + # En-tĂȘtes standards + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} + +# ------------------------------------------------------------------------------ +# OPTION 2 : Service direct des fichiers statiques (React Build SPA) +# ------------------------------------------------------------------------------ +# server { +# listen 80; +# listen [::]:80; +# server_name your-domain.com; +# +# # Chemin absolu vers le dossier build de l'application +# root /var/www/afficheurs-ferrovia/build; +# index index.html; +# +# # Compression Gzip +# gzip on; +# gzip_vary on; +# gzip_min_length 1024; +# gzip_proxied any; +# gzip_comp_level 6; +# gzip_types +# text/plain +# text/css +# text/xml +# text/javascript +# application/json +# application/javascript +# application/x-javascript +# application/xml +# application/xml+rss +# image/svg+xml; +# +# # Routage SPA React (indispensable pour /afficheurs/*, /departs, /arrivees, etc.) +# location / { +# try_files $uri $uri/ /index.html; +# } +# +# # Cache pour les assets statiques avec hash (JS/CSS) +# location ~* \.(?:css|js)$ { +# expires 1y; +# access_log off; +# add_header Cache-Control "public, max-age=31536000, immutable"; +# } +# +# # Cache pour les mĂ©dias, polices et images +# location ~* \.(?:ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|mp3|wav)$ { +# expires 30d; +# access_log off; +# add_header Cache-Control "public, max-age=2592000"; +# } +# +# # Pas de cache pour index.html, manifest.json et service-worker (mise Ă  jour PWA instantanĂ©e) +# location ~* (?:index\.html|manifest\.json|service-worker\.js)$ { +# expires -1; +# add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; +# } +# } diff --git a/public/vigipirate.png b/public/vigipirate.png new file mode 100644 index 0000000..f3320ec Binary files /dev/null and b/public/vigipirate.png differ diff --git a/src/pages/SelectAfficheur.js b/src/pages/SelectAfficheur.js index 9b754da..a58d6a1 100644 --- a/src/pages/SelectAfficheur.js +++ b/src/pages/SelectAfficheur.js @@ -16,6 +16,29 @@ export default function SelectAfficheur({ onBack, navigate }) { const [manualPlatform, setManualPlatform] = useState(''); const [loading, setLoading] = useState(true); + // État des pages sĂ©lectionnĂ©es pour les afficheurs AFL + const [aflPages, setAflPages] = useState({ + horaires: true, + infos: false, + securite1: false, + securite2: false, + corresp: false + }); + + const toggleAflPage = (key) => { + setAflPages(prev => ({ ...prev, [key]: !prev[key] })); + }; + + const selectAllAflPages = (all) => { + setAflPages({ + horaires: true, + infos: all, + securite1: all, + securite2: all, + corresp: all + }); + }; + // Decode and format station name for display const decodedGare = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') @@ -49,7 +72,19 @@ export default function SelectAfficheur({ onBack, navigate }) { const handleGeneral = () => { if (typeof navigate === 'function') { const kebab = gareParam ? String(gareParam).trim() : ''; - navigate(`${prefix}/afficheurs/${modeParam}/${typeParam}?gare=${encodeURIComponent(kebab)}`); + let url = `${prefix}/afficheurs/${modeParam}/${typeParam}?gare=${encodeURIComponent(kebab)}`; + + if (typeParam === 'afl') { + const activeKeys = Object.entries(aflPages) + .filter(([_, active]) => active) + .map(([key]) => key); + + if (activeKeys.length > 0) { + url += `&pages=${activeKeys.join(',')}`; + } + } + + navigate(url); } }; @@ -65,9 +100,16 @@ export default function SelectAfficheur({ onBack, navigate }) { } }; + const handleSinglePage = (pageKey) => { + if (typeof navigate === 'function') { + const kebab = gareParam ? String(gareParam).trim() : ''; + navigate(`${prefix}/afficheurs/${modeParam}/${typeParam}?gare=${encodeURIComponent(kebab)}&page=${pageKey}`); + } + }; + return (
- Pour la gare de {decodedGare} en mode {modeParam === 'arrivees' ? 'Arrivées' : 'Départs'}. + Pour la gare de {decodedGare} en mode {modeParam === 'arrivees' ? 'Arrivées' : 'Départs'} (Style : {typeParam.toUpperCase()}).

@@ -117,11 +159,112 @@ export default function SelectAfficheur({ onBack, navigate }) { alignItems: 'flex-start', background: '#fafbfc' }}> -
+

Afficheur Général

-

- Affiche la liste complÚte de tous les trains programmés pour cette gare. +

+ Affiche la liste des trains programmés avec possibilité d'inclure les pages d'informations et services.

+ + {typeParam === 'afl' && ( +
+
+ + Pages Ă  inclure dans la rotation : + +
+ + | + +
+
+ +
+ + + + + + + + + +
+
+ )}
+ {typeParam === 'afl' && ( +
+

+ AccÚs direct à une page AFL spécifique (fixe) : +

+
+ + + + + + +
+
+ )} +
); } + diff --git a/src/pages/afficheurs/afl/AFLArrivees.js b/src/pages/afficheurs/afl/AFLArrivees.js index 4a117c7..3765d51 100644 --- a/src/pages/afficheurs/afl/AFLArrivees.js +++ b/src/pages/afficheurs/afl/AFLArrivees.js @@ -7,6 +7,9 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import { getSourceFromPath } from '../../../services/railwayService'; import { getSncfTimetable } from '../../../services/sncfApi'; +import AFLSecurityPage from './components/AFLSecurityPage'; +import AFLCorrespondancesPage from './components/AFLCorrespondancesPage'; +import AFLInformationsPage from './components/AFLInformationsPage'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -132,11 +135,35 @@ export default function AFLArrivees(){ // gérer les +, décoder et remplacer les tirets par des espaces 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; + // Support pour verrouiller l'affichage sur une page spécifique via l'URL (?page=...) + // ?page=1 -> Horaires Page 1 + // ?page=2 -> Horaires Page 2 + // ?page=3 | ?page=info | ?page=informations -> Informations + // ?page=4 | ?page=securite | ?page=securite1 -> Sécurité (Image 2) + // ?page=securite2 -> Sécurité (Image 3) + // ?page=5 | ?page=correspondances -> Correspondances + const rawPageParam = params.get('page') ? String(params.get('page')).toLowerCase().trim() : null; + + const parseLockedStep = (param) => { + if (!param) return null; + if (param === '1' || param === 'p1' || param === 'horaires1' || param === 'horaires_1') return { type: 'HORAIRES_1' }; + if (param === '2' || param === 'p2' || param === 'horaires2' || param === 'horaires_2') return { type: 'HORAIRES_2' }; + if (param === '3' || param === 'info' || param === 'information' || param === 'informations' || param === 'infos') return { type: 'INFORMATIONS' }; + if (param === '4' || param === 'securite' || param === 'security' || param === 'safety' || param === 'securite1' || param === 'securite_1') return { type: 'SECURITE', variant: 1 }; + if (param === 'securite2' || param === 'securite_2' || param === 'vigipirate' || param === '3117') return { type: 'SECURITE', variant: 2 }; + if (param === '5' || param === 'correspondance' || param === 'correspondances' || param === 'corresp') return { type: 'CORRESPONDANCES' }; + const n = parseInt(param, 10); + if (!isNaN(n)) { + if (n === 1) return { type: 'HORAIRES_1' }; + if (n === 2) return { type: 'HORAIRES_2' }; + if (n === 3) return { type: 'INFORMATIONS' }; + if (n === 4) return { type: 'SECURITE', variant: 1 }; + if (n >= 5) return { type: 'CORRESPONDANCES' }; + } + return null; + }; + + const lockedStep = parseLockedStep(rawPageParam); // debug flag activable via ?debug=1 or ?debug=true const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true'); @@ -158,7 +185,7 @@ export default function AFLArrivees(){ const [perturbations, setPerturbations] = useState([]); // eslint-disable-next-line no-unused-vars const [stationType, setStationType] = useState(null); - const [, setTravaux] = useState([]); + const [travaux, setTravaux] = useState([]); const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); const [, setLoading] = useState(false); const [, setError] = useState(null); @@ -685,7 +712,7 @@ export default function AFLArrivees(){ } }; }); - setHoraires(formatted.slice(0, 10)); + setHoraires(formatted); setAllHoraires(formatted); } } catch (e) { @@ -861,30 +888,54 @@ export default function AFLArrivees(){ const tomorrowStr = localDateStr(tomorrow); const circulatesOn = (h, d) => { + if (!h || !d) return false; const pad = (n) => String(n).padStart(2, '0'); - const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } const customDaysRaw = h.jours_personnalises; - if (!customDaysRaw) 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 (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } } - if (Array.isArray(customDays)) { - const match = customDays.find(item => item.date === targetDateStr); - if (match) { - return Boolean(match.circule); - } + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); } - return false; + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; }; // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* @@ -1011,6 +1062,10 @@ export default function AFLArrivees(){ let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); @@ -1086,7 +1141,10 @@ export default function AFLArrivees(){ // Helpers pour perturbations (memoized plus haut) - const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0); + // Gestion du cycle d'alternance des pages : + // Horaires P1 -> Horaires P2 (si > 5 trains) -> Informations -> Sécurité & Sûreté (alternance Img 2 / Img 3) -> Correspondances + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [securityVariant, setSecurityVariant] = useState(2); const [alternateDelay, setAlternateDelay] = useState(false); useEffect(() => { @@ -1095,33 +1153,96 @@ export default function AFLArrivees(){ }, 2000); return () => clearInterval(delayTimer); }, []); - - const getPageBounds = useCallback((pIndex) => { - if (pIndex === 0) return { start: 0, end: 5 }; - const start = 5 + (pIndex - 1) * 8; - return { start, end: start + 8 }; - }, []); - const totalPages = Math.max(1, horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8)); + // Construction des étapes de rotation : + // Les pages Informations, Sécurité 1 et 2, Correspondances ne sont affichées STRICTEMENT que si demandées dans l'URL (?pages=... ou ?infos=1 etc.) + const pagesParam = params.get('pages'); + const selectedPagesList = pagesParam ? pagesParam.split(',').map(s => s.trim().toLowerCase()) : null; + const hasFlag = (key) => params.get(key) === '1' || params.get(key) === 'true'; + + const enableP1 = selectedPagesList + ? (selectedPagesList.includes('p1') || selectedPagesList.includes('horaires') || selectedPagesList.includes('horaires_1') || selectedPagesList.includes('page1')) + : (hasFlag('p1') || hasFlag('horaires') || !pagesParam); + + const enableP2 = selectedPagesList + ? (selectedPagesList.includes('p2') || selectedPagesList.includes('horaires') || selectedPagesList.includes('horaires_2') || selectedPagesList.includes('page2')) + : (hasFlag('p2') || hasFlag('horaires') || !pagesParam); + + const enableInfos = selectedPagesList + ? (selectedPagesList.includes('infos') || selectedPagesList.includes('info') || selectedPagesList.includes('informations') || selectedPagesList.includes('information')) + : (hasFlag('infos') || hasFlag('info') || hasFlag('informations') || hasFlag('information')); + + const enableSecurite1 = selectedPagesList + ? (selectedPagesList.includes('securite1') || selectedPagesList.includes('securite_1') || (selectedPagesList.includes('securite') && !selectedPagesList.includes('securite2'))) + : (hasFlag('securite1') || hasFlag('securite_1') || (hasFlag('securite') && !hasFlag('securite2'))); + + const enableSecurite2 = selectedPagesList + ? (selectedPagesList.includes('securite2') || selectedPagesList.includes('securite_2') || selectedPagesList.includes('vigipirate') || selectedPagesList.includes('3117')) + : (hasFlag('securite2') || hasFlag('securite_2') || hasFlag('vigipirate') || hasFlag('3117')); + + const enableCorresp = selectedPagesList + ? (selectedPagesList.includes('corresp') || selectedPagesList.includes('correspondance') || selectedPagesList.includes('correspondances')) + : (hasFlag('corresp') || hasFlag('correspondance') || hasFlag('correspondances')); + + const cycleSteps = []; + if (enableP1) cycleSteps.push({ type: 'HORAIRES_1' }); + if (enableP2 && horaires.length > 5) cycleSteps.push({ type: 'HORAIRES_2' }); + if (enableInfos) cycleSteps.push({ type: 'INFORMATIONS' }); + if (enableSecurite1) cycleSteps.push({ type: 'SECURITE', variant: 1 }); + if (enableSecurite2) cycleSteps.push({ type: 'SECURITE', variant: 2 }); + if (enableCorresp) cycleSteps.push({ type: 'CORRESPONDANCES' }); + + if (cycleSteps.length === 0) { + cycleSteps.push({ type: 'HORAIRES_1' }); + if (horaires.length > 5) cycleSteps.push({ type: 'HORAIRES_2' }); + } useEffect(() => { - if (lockedPageIndex !== null) { - setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); - return; - } - const pageInterval = setInterval(() => { - setPageIndex((prev) => { - const tot = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); - if (tot <= 1) return 0; - return (prev + 1) % tot; - }); - }, 10000); // Change page every 10 seconds - return () => clearInterval(pageInterval); - }, [horaires.length, lockedPageIndex, totalPages]); + if (lockedStep !== null) return; + if (cycleSteps.length <= 1) return; - const { start, end } = getPageBounds(pageIndex); - const displayedHoraires = horaires.slice(start, end); - const isPage1 = pageIndex === 0; + const pageInterval = setInterval(() => { + setCurrentStepIndex((prev) => (prev + 1) % cycleSteps.length); + }, 10000); // 10 secondes par page + + return () => clearInterval(pageInterval); + }, [cycleSteps.length, lockedStep]); + + const activeStep = lockedStep || cycleSteps[currentStepIndex % cycleSteps.length] || { type: 'HORAIRES_1' }; + + const effectiveGareName = gareDisplay || gareParam || (horaires[0]?.gare_arrivee_nom) || (horaires[0]?.gare_arrivee) || (horaires[0]?.nom_gare) || (horaires[0]?.station_name) || ''; + + if (activeStep.type === 'INFORMATIONS') { + return ( + + ); + } + + if (activeStep.type === 'SECURITE') { + return ( + + ); + } + + if (activeStep.type === 'CORRESPONDANCES') { + return ( + + ); + } + + const isPage1 = activeStep.type === 'HORAIRES_1'; + const displayedHoraires = isPage1 ? horaires.slice(0, 5) : horaires.slice(5, 13); + const totalPages = horaires.length <= 5 ? 1 : 2; + const pageIndex = isPage1 ? 0 : 1; return (
@@ -1178,26 +1299,8 @@ export default function AFLArrivees(){ '' ).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 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'); let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue'; @@ -1207,7 +1310,7 @@ export default function AFLArrivees(){ const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé'); - let showPlatform = !isSuppressed && Boolean(rawPlatform) && !isCG; + let showPlatform = !isSuppressed && Boolean(rawPlatform); const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard'); const pillClass = isSuppressed ? 'afl-arr-pill--suppressed' : isDelayed ? 'afl-arr-pill--delayed' : 'afl-arr-pill--ontime'; diff --git a/src/pages/afficheurs/afl/AFLArriveesQuais.js b/src/pages/afficheurs/afl/AFLArriveesQuais.js index c000de4..9b982ac 100644 --- a/src/pages/afficheurs/afl/AFLArriveesQuais.js +++ b/src/pages/afficheurs/afl/AFLArriveesQuais.js @@ -460,31 +460,55 @@ const AFLArriveesQuais = () => { const tomorrowStr = localDateStr(tomorrow); const circulatesOn = (h, d) => { - const pad = (n) => String(n).padStart(2, '0'); - const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + if (!h || !d) return false; + const pad = (n) => String(n).padStart(2, '0'); + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); - const customDaysRaw = h.jours_personnalises; - if (!customDaysRaw) return false; + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } - let customDays = []; - if (Array.isArray(customDaysRaw)) { - customDays = customDaysRaw; - } else if (typeof customDaysRaw === 'string') { - try { - customDays = JSON.parse(customDaysRaw); - } catch (e) { - return false; - } - } + const customDaysRaw = h.jours_personnalises; + if (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } + } - if (Array.isArray(customDays)) { - const match = customDays.find(item => item.date === targetDateStr); - if (match) { - return Boolean(match.circule); - } - } - return false; - }; + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); + } + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; + }; const parseDateOnly = (v) => { if (!v) return null; @@ -608,6 +632,10 @@ const AFLArriveesQuais = () => { let serviceDateStr = ''; if (todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a, b) => a._depHM - b._depHM); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM); diff --git a/src/pages/afficheurs/afl/AFLDeparts.js b/src/pages/afficheurs/afl/AFLDeparts.js index 795540a..7911fe4 100644 --- a/src/pages/afficheurs/afl/AFLDeparts.js +++ b/src/pages/afficheurs/afl/AFLDeparts.js @@ -7,6 +7,9 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import { getSourceFromPath } from '../../../services/railwayService'; import { getSncfTimetable } from '../../../services/sncfApi'; +import AFLSecurityPage from './components/AFLSecurityPage'; +import AFLCorrespondancesPage from './components/AFLCorrespondancesPage'; +import AFLInformationsPage from './components/AFLInformationsPage'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -135,11 +138,35 @@ export default function AFLDeparts(){ // gérer les +, décoder et remplacer les tirets par des espaces 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; + // Support pour verrouiller l'affichage sur une page spécifique via l'URL (?page=...) + // ?page=1 -> Horaires Page 1 + // ?page=2 -> Horaires Page 2 + // ?page=3 | ?page=info | ?page=informations -> Informations + // ?page=4 | ?page=securite | ?page=securite1 -> Sécurité (Image 2) + // ?page=securite2 -> Sécurité (Image 3) + // ?page=5 | ?page=correspondances -> Correspondances + const rawPageParam = params.get('page') ? String(params.get('page')).toLowerCase().trim() : null; + + const parseLockedStep = (param) => { + if (!param) return null; + if (param === '1' || param === 'p1' || param === 'horaires1' || param === 'horaires_1') return { type: 'HORAIRES_1' }; + if (param === '2' || param === 'p2' || param === 'horaires2' || param === 'horaires_2') return { type: 'HORAIRES_2' }; + if (param === '3' || param === 'info' || param === 'information' || param === 'informations' || param === 'infos') return { type: 'INFORMATIONS' }; + if (param === '4' || param === 'securite' || param === 'security' || param === 'safety' || param === 'securite1' || param === 'securite_1') return { type: 'SECURITE', variant: 1 }; + if (param === 'securite2' || param === 'securite_2' || param === 'vigipirate' || param === '3117') return { type: 'SECURITE', variant: 2 }; + if (param === '5' || param === 'correspondance' || param === 'correspondances' || param === 'corresp') return { type: 'CORRESPONDANCES' }; + const n = parseInt(param, 10); + if (!isNaN(n)) { + if (n === 1) return { type: 'HORAIRES_1' }; + if (n === 2) return { type: 'HORAIRES_2' }; + if (n === 3) return { type: 'INFORMATIONS' }; + if (n === 4) return { type: 'SECURITE', variant: 1 }; + if (n >= 5) return { type: 'CORRESPONDANCES' }; + } + return null; + }; + + const lockedStep = parseLockedStep(rawPageParam); // debug flag activable via ?debug=1 or ?debug=true const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true'); @@ -164,7 +191,7 @@ export default function AFLDeparts(){ // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...) // eslint-disable-next-line no-unused-vars const [stationType, setStationType] = useState(null); - const [, setTravaux] = useState([]); + const [travaux, setTravaux] = useState([]); const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); const [, setLoading] = useState(false); const [, setError] = useState(null); @@ -695,7 +722,7 @@ export default function AFLDeparts(){ causeDetails: t.cause_retard || null } })); - setHoraires(formatted.slice(0, 10)); + setHoraires(formatted); setAllHoraires(formatted); } } catch (e) { @@ -886,30 +913,54 @@ export default function AFLDeparts(){ const tomorrowStr = localDateStr(tomorrow); const circulatesOn = (h, d) => { + if (!h || !d) return false; const pad = (n) => String(n).padStart(2, '0'); - const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } const customDaysRaw = h.jours_personnalises; - if (!customDaysRaw) 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 (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } } - if (Array.isArray(customDays)) { - const match = customDays.find(item => item.date === targetDateStr); - if (match) { - return Boolean(match.circule); - } + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); } - return false; + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; }; // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* @@ -1036,6 +1087,10 @@ export default function AFLDeparts(){ let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); @@ -1111,7 +1166,10 @@ export default function AFLDeparts(){ // Helpers pour perturbations (memoized plus haut) - const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0); + // Gestion du cycle d'alternance des pages : + // Horaires P1 -> Horaires P2 (si > 5 trains) -> Informations -> Sécurité & Sûreté (alternance Img 2 / Img 3) -> Correspondances + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [securityVariant, setSecurityVariant] = useState(2); const [alternateDelay, setAlternateDelay] = useState(false); useEffect(() => { @@ -1120,33 +1178,96 @@ export default function AFLDeparts(){ }, 2000); return () => clearInterval(delayTimer); }, []); - - const getPageBounds = useCallback((pIndex) => { - if (pIndex === 0) return { start: 0, end: 5 }; - const start = 5 + (pIndex - 1) * 8; - return { start, end: start + 8 }; - }, []); - const totalPages = Math.max(1, horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8)); + // Construction des étapes de rotation : + // Les pages Informations, Sécurité 1 et 2, Correspondances ne sont affichées STRICTEMENT que si demandées dans l'URL (?pages=... ou ?infos=1 etc.) + const pagesParam = params.get('pages'); + const selectedPagesList = pagesParam ? pagesParam.split(',').map(s => s.trim().toLowerCase()) : null; + const hasFlag = (key) => params.get(key) === '1' || params.get(key) === 'true'; + + const enableP1 = selectedPagesList + ? (selectedPagesList.includes('p1') || selectedPagesList.includes('horaires') || selectedPagesList.includes('horaires_1') || selectedPagesList.includes('page1')) + : (hasFlag('p1') || hasFlag('horaires') || !pagesParam); + + const enableP2 = selectedPagesList + ? (selectedPagesList.includes('p2') || selectedPagesList.includes('horaires') || selectedPagesList.includes('horaires_2') || selectedPagesList.includes('page2')) + : (hasFlag('p2') || hasFlag('horaires') || !pagesParam); + + const enableInfos = selectedPagesList + ? (selectedPagesList.includes('infos') || selectedPagesList.includes('info') || selectedPagesList.includes('informations') || selectedPagesList.includes('information')) + : (hasFlag('infos') || hasFlag('info') || hasFlag('informations') || hasFlag('information')); + + const enableSecurite1 = selectedPagesList + ? (selectedPagesList.includes('securite1') || selectedPagesList.includes('securite_1') || (selectedPagesList.includes('securite') && !selectedPagesList.includes('securite2'))) + : (hasFlag('securite1') || hasFlag('securite_1') || (hasFlag('securite') && !hasFlag('securite2'))); + + const enableSecurite2 = selectedPagesList + ? (selectedPagesList.includes('securite2') || selectedPagesList.includes('securite_2') || selectedPagesList.includes('vigipirate') || selectedPagesList.includes('3117')) + : (hasFlag('securite2') || hasFlag('securite_2') || hasFlag('vigipirate') || hasFlag('3117')); + + const enableCorresp = selectedPagesList + ? (selectedPagesList.includes('corresp') || selectedPagesList.includes('correspondance') || selectedPagesList.includes('correspondances')) + : (hasFlag('corresp') || hasFlag('correspondance') || hasFlag('correspondances')); + + const cycleSteps = []; + if (enableP1) cycleSteps.push({ type: 'HORAIRES_1' }); + if (enableP2 && horaires.length > 5) cycleSteps.push({ type: 'HORAIRES_2' }); + if (enableInfos) cycleSteps.push({ type: 'INFORMATIONS' }); + if (enableSecurite1) cycleSteps.push({ type: 'SECURITE', variant: 1 }); + if (enableSecurite2) cycleSteps.push({ type: 'SECURITE', variant: 2 }); + if (enableCorresp) cycleSteps.push({ type: 'CORRESPONDANCES' }); + + if (cycleSteps.length === 0) { + cycleSteps.push({ type: 'HORAIRES_1' }); + if (horaires.length > 5) cycleSteps.push({ type: 'HORAIRES_2' }); + } useEffect(() => { - if (lockedPageIndex !== null) { - setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); - return; - } - const pageInterval = setInterval(() => { - setPageIndex((prev) => { - const tot = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); - if (tot <= 1) return 0; - return (prev + 1) % tot; - }); - }, 10000); // Change page every 10 seconds - return () => clearInterval(pageInterval); - }, [horaires.length, lockedPageIndex, totalPages]); + if (lockedStep !== null) return; + if (cycleSteps.length <= 1) return; - const { start, end } = getPageBounds(pageIndex); - const displayedHoraires = horaires.slice(start, end); - const isPage1 = pageIndex === 0; + const pageInterval = setInterval(() => { + setCurrentStepIndex((prev) => (prev + 1) % cycleSteps.length); + }, 10000); // 10 secondes par page + + return () => clearInterval(pageInterval); + }, [cycleSteps.length, lockedStep]); + + const activeStep = lockedStep || cycleSteps[currentStepIndex % cycleSteps.length] || { type: 'HORAIRES_1' }; + + const effectiveGareName = gareDisplay || gareParam || (horaires[0]?.gare_depart_nom) || (horaires[0]?.gare_depart) || (horaires[0]?.nom_gare) || (horaires[0]?.station_name) || ''; + + if (activeStep.type === 'INFORMATIONS') { + return ( + + ); + } + + if (activeStep.type === 'SECURITE') { + return ( + + ); + } + + if (activeStep.type === 'CORRESPONDANCES') { + return ( + + ); + } + + const isPage1 = activeStep.type === 'HORAIRES_1'; + const displayedHoraires = isPage1 ? horaires.slice(0, 5) : horaires.slice(5, 13); + const totalPages = horaires.length <= 5 ? 1 : 2; + const pageIndex = isPage1 ? 0 : 1; return (
@@ -1203,26 +1324,8 @@ export default function AFLDeparts(){ '' ).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 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'); let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); const destination = horaire.gare_arrivee_nom || ''; @@ -1232,7 +1335,7 @@ export default function AFLDeparts(){ const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimĂ©'); - let showPlatform = !isSuppressed && Boolean(rawPlatform) && !isCG; + let showPlatform = !isSuppressed && Boolean(rawPlatform); const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard'); const pillClass = isSuppressed ? 'afl-pill--suppressed' : isDelayed ? 'afl-pill--delayed' : 'afl-pill--ontime'; diff --git a/src/pages/afficheurs/afl/AFLDepartsQuais.js b/src/pages/afficheurs/afl/AFLDepartsQuais.js index d13b011..dd7bb53 100644 --- a/src/pages/afficheurs/afl/AFLDepartsQuais.js +++ b/src/pages/afficheurs/afl/AFLDepartsQuais.js @@ -530,31 +530,55 @@ const AFLDepartsQuais = () => { const tomorrowStr = localDateStr(tomorrow); const circulatesOn = (h, d) => { - const pad = (n) => String(n).padStart(2, '0'); - const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + if (!h || !d) return false; + const pad = (n) => String(n).padStart(2, '0'); + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); - const customDaysRaw = h.jours_personnalises; - if (!customDaysRaw) return false; + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } - let customDays = []; - if (Array.isArray(customDaysRaw)) { - customDays = customDaysRaw; - } else if (typeof customDaysRaw === 'string') { - try { - customDays = JSON.parse(customDaysRaw); - } catch (e) { - return false; - } - } + const customDaysRaw = h.jours_personnalises; + if (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } + } - if (Array.isArray(customDays)) { - const match = customDays.find(item => item.date === targetDateStr); - if (match) { - return Boolean(match.circule); - } - } - return false; - }; + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); + } + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; + }; const parseDateOnly = (v) => { if (!v) return null; @@ -682,6 +706,10 @@ const AFLDepartsQuais = () => { let serviceDateStr = ''; if (todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a, b) => a._depHM - b._depHM); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM); diff --git a/src/pages/afficheurs/afl/components/AFLCorrespondancesPage.css b/src/pages/afficheurs/afl/components/AFLCorrespondancesPage.css new file mode 100644 index 0000000..2191811 --- /dev/null +++ b/src/pages/afficheurs/afl/components/AFLCorrespondancesPage.css @@ -0,0 +1,201 @@ +/* AFL CORRESPONDANCES PAGE - DESIGN STRICT CONFORME A L'IMAGE 1 */ + +.afl-corresp-page { + font-family: 'Achemine', 'Segoe UI', Roboto, sans-serif !important; + background-color: #1f2125 !important; + color: #ffffff !important; + width: 100% !important; + height: 100% !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + user-select: none !important; + box-sizing: border-box !important; +} + +.afl-corresp-page * { + box-sizing: border-box !important; +} + +/* ========================================================================== + EN-TÊTE GRIS ARDOISE (108px) + ========================================================================== */ +.afl-corresp-header { + height: 108px !important; + min-height: 108px !important; + max-height: 108px !important; + background-color: #34373d !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 48px !important; + border-bottom: 2px solid #4a4e56 !important; +} + +.afl-corresp-header-left { + display: flex !important; + align-items: center !important; + gap: 22px !important; +} + +.afl-corresp-header-title { + font-size: 54px !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: -0.5px !important; + line-height: 1 !important; +} + +.afl-corresp-header-right { + display: flex !important; + align-items: center !important; +} + +.afl-corresp-clock { + font-size: 56px !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + letter-spacing: -0.96px !important; +} + +/* ========================================================================== + LISTE DES LIGNES EN CORRESPONDANCE + ========================================================================== */ +.afl-corresp-list { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + padding: 0 !important; + margin: 0 !important; + background-color: #1f2125 !important; +} + +.afl-corresp-group { + display: flex !important; + flex-direction: column !important; + width: 100% !important; +} + +.afl-corresp-row { + height: 108px !important; + min-height: 108px !important; + max-height: 108px !important; + display: flex !important; + align-items: center !important; + width: 100% !important; + padding: 0 48px !important; + border-bottom: 1.5px solid #3c4048 !important; +} + +/* Colonne 1 : Bus/Car/Tram + NumĂ©ro de ligne */ +.afl-corresp-col-line { + display: flex !important; + align-items: center !important; + gap: 14px !important; + width: 180px !important; + min-width: 180px !important; +} + +.afl-corresp-icon-box { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 58px !important; + height: 58px !important; + border: 2px solid rgba(255, 255, 255, 0.4) !important; + border-radius: 8px !important; + background: rgba(255, 255, 255, 0.05) !important; + flex-shrink: 0 !important; +} + +.afl-corresp-line-badge { + background-color: #4a4e57 !important; + color: #ffffff !important; + font-size: 32px !important; + font-weight: 900 !important; + padding: 6px 14px !important; + border-radius: 8px !important; + line-height: 1 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important; + min-width: 65px !important; + text-align: center !important; +} + +.afl-corresp-line-spacer { + width: 180px !important; + min-width: 180px !important; +} + +/* Colonne 2 : ArrĂȘt - Destination */ +.afl-corresp-col-dest { + flex: 1 !important; + overflow: hidden !important; + padding-right: 30px !important; + white-space: nowrap !important; + text-overflow: ellipsis !important; +} + +.afl-corresp-dest-text { + font-size: 48px !important; + font-weight: 700 !important; + color: #ffffff !important; + letter-spacing: -0.5px !important; + line-height: 1 !important; +} + +/* Colonne 3 : 2 BoĂźtes des minutes d'attente (Compatibles XXhXX min) */ +.afl-corresp-col-times { + display: flex !important; + align-items: center !important; + gap: 20px !important; + flex-shrink: 0 !important; +} + +.afl-corresp-time-box { + background-color: #d6dbe2 !important; + color: #111111 !important; + border-radius: 10px !important; + min-width: 155px !important; + height: 68px !important; + padding: 0 16px !important; + display: flex !important; + align-items: baseline !important; + justify-content: center !important; + gap: 4px !important; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.35) !important; +} + +.afl-corresp-time-val { + font-size: 44px !important; + font-weight: 900 !important; + line-height: 1 !important; + letter-spacing: -1px !important; +} + +.afl-corresp-time-unit { + font-size: 24px !important; + font-weight: 600 !important; + line-height: 1 !important; + opacity: 0.9 !important; + margin-left: 2px !important; +} + +/* Message si liste vide */ +.afl-corresp-empty { + flex: 1 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + color: #cccccc !important; + font-size: 32px !important; + font-weight: 500 !important; + padding: 40px !important; + text-align: center !important; +} diff --git a/src/pages/afficheurs/afl/components/AFLCorrespondancesPage.js b/src/pages/afficheurs/afl/components/AFLCorrespondancesPage.js new file mode 100644 index 0000000..333298d --- /dev/null +++ b/src/pages/afficheurs/afl/components/AFLCorrespondancesPage.js @@ -0,0 +1,214 @@ +import React, { useState, useEffect } from 'react'; +import './AFLCorrespondancesPage.css'; +import { getCorrespondancesForStation, computeLiveWaitTimes, formatWaitTimeDisplay } from '../../../../services/correspondancesService'; + +// Horloge pour l'en-tĂȘte de correspondances +function CorrespondancesClock({ now }) { + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + + return ( +
+ {hh}:{mm} +
+ ); +} + +// IcÎne de PÎle d'échange / Gare Correspondances (bùtiment + trajet en pointillés) +const StationTransferIcon = ({ size = '52px', color = '#f5c518' }) => ( + + {/* Bùtiment gare / PÎle d'échange */} + + + + + + {/* Trajet pointillé de correspondance */} + + + +); + +// IcÎne de Transport Multimodal (Bus, Car, Tram) +const TransitCorrespIcon = ({ type = 'bus', size = '56px', color = '#ffffff' }) => ( +
+ {type === 'tram' ? ( + + + + + + + + + + ) : type === 'car' ? ( + + + + + + + + + + ) : ( + + + + + + + + + )} +
+); + +export default function AFLCorrespondancesPage({ gareName = '' }) { + const [correspondances, setCorrespondances] = useState([]); + const [, setLoading] = useState(true); + const [currentTime, setCurrentTime] = useState(new Date()); + + // Horloge en direct pour synchronisation exacte des décomptes + useEffect(() => { + const clockId = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(clockId); + }, []); + + // Charger les correspondances aux alentours de la gare affichée + useEffect(() => { + let mounted = true; + + async function loadData() { + try { + const list = await getCorrespondancesForStation(gareName); + if (mounted) { + setCorrespondances(list); + setLoading(false); + } + } catch (err) { + console.warn('[AFLCorrespondances] Erreur chargement correspondances:', err); + if (mounted) setLoading(false); + } + } + + loadData(); + + // Actualiser les correspondances toutes les 30 secondes + const refreshTimer = setInterval(loadData, 30000); + return () => { + mounted = false; + clearInterval(refreshTimer); + }; + }, [gareName]); + + // Si aucune correspondance n'est disponible + if (!correspondances || correspondances.length === 0) { + return ( +
+
+
+ +
Correspondances
+
+
+ +
+
+
+ Aucune correspondance directe actuellement signalée dans cette gare. +
+
+ ); + } + + return ( +
+ {/* EN-TÊTE GRIS ARDOISE */} +
+
+ +
Correspondances
+
+ +
+ +
+
+ + {/* LISTE DES CORRESPONDANCES (Strictement conforme Ă  l'Image 1) */} +
+ {correspondances.map((cGroup) => ( +
+ {cGroup.destinations.map((dest, dIdx) => { + const isFirstInGroup = dIdx === 0; + + // Calcul en direct à la seconde prÚs des 2 prochains départs (comme Image 1) + const times = computeLiveWaitTimes(dest, currentTime).slice(0, 2); + + return ( +
+ {/* COLONNE GAUCHE : Picto Transport et Badge Ligne UNIQUEMENT sur la 1Ăšre destination du groupe */} +
+ {isFirstInGroup ? ( + <> + +
+ {cGroup.line} +
+ + ) : ( +
+ )} +
+ + {/* COLONNE CENTRE : ArrĂȘt - Destination */} +
+ {dest.name} +
+ + {/* COLONNE DROITE : 2 BoĂźtes Minutes d'attente compatibles XXhXX min */} +
+ {times.map((wait, wIdx) => { + const formatted = formatWaitTimeDisplay(wait); + return ( +
+ {formatted.val} + {formatted.unit && {formatted.unit}} +
+ ); + })} +
+
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/src/pages/afficheurs/afl/components/AFLInformationsPage.css b/src/pages/afficheurs/afl/components/AFLInformationsPage.css new file mode 100644 index 0000000..e11fc26 --- /dev/null +++ b/src/pages/afficheurs/afl/components/AFLInformationsPage.css @@ -0,0 +1,242 @@ +/* AFL INFORMATIONS PAGE - DESIGN AUX COULEURS AFL */ + +.afl-info-page { + font-family: 'Achemine', 'Segoe UI', Roboto, sans-serif !important; + color: #ffffff !important; + width: 100% !important; + height: 100% !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + user-select: none !important; + box-sizing: border-box !important; +} + +.afl-info-page * { + box-sizing: border-box !important; +} + +/* ThĂšme DĂ©parts (Bleu) */ +.afl-info--departs { + background-color: #002d62 !important; +} + +.afl-info--departs .afl-info-header { + background-color: #005fa9 !important; + border-bottom: 2px solid #ffffff !important; +} + +/* ThĂšme ArrivĂ©es (Vert) */ +.afl-info--arrivees { + background-color: #173e31 !important; +} + +.afl-info--arrivees .afl-info-header { + background-color: #337862 !important; + border-bottom: 2px solid #ffffff !important; +} + +/* ========================================================================== + EN-TÊTE AFL (108px) + ========================================================================== */ +.afl-info-header { + height: 108px !important; + min-height: 108px !important; + max-height: 108px !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 48px !important; +} + +.afl-info-header-left { + display: flex !important; + align-items: center !important; + gap: 22px !important; +} + +.afl-info-header-title { + font-size: 54px !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: -0.5px !important; + line-height: 1 !important; +} + +.afl-info-header-right { + display: flex !important; + align-items: center !important; +} + +.afl-info-clock { + font-size: 56px !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + letter-spacing: -0.96px !important; +} + +/* ========================================================================== + CONTENU PRINCIPAL + ========================================================================== */ +.afl-info-content { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + padding: 36px 60px !important; + overflow: hidden !important; +} + +/* Grille des cartes en cas de perturbations ou travaux */ +.afl-info-cards-container { + display: flex !important; + flex-direction: column !important; + gap: 24px !important; + height: 100% !important; + justify-content: center !important; +} + +.afl-info-card { + background: rgba(255, 255, 255, 0.08) !important; + border-radius: 16px !important; + padding: 24px 32px !important; + border: 1.5px solid rgba(255, 255, 255, 0.2) !important; + backdrop-filter: blur(8px) !important; +} + +.afl-info-card--perturbation { + border-left: 10px solid #f7a81b !important; +} + +.afl-info-card--travaux { + border-left: 10px solid #3cd070 !important; +} + +.afl-info-card-header { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + margin-bottom: 16px !important; +} + +.afl-info-badge { + display: flex !important; + align-items: center !important; + gap: 10px !important; + font-size: 26px !important; + font-weight: 800 !important; + text-transform: uppercase !important; + letter-spacing: 0.8px !important; +} + +.afl-info-badge--alert { + color: #f7a81b !important; +} + +.afl-info-badge--travaux { + color: #3cd070 !important; +} + +.afl-info-station-name { + font-size: 32px !important; + font-weight: 700 !important; + color: #ffffff !important; + opacity: 0.9 !important; +} + +.afl-info-card-body { + display: flex !important; + flex-direction: column !important; + gap: 14px !important; +} + +.afl-info-perturb-item, +.afl-info-travaux-item { + display: flex !important; + flex-direction: column !important; + gap: 4px !important; +} + +.afl-info-perturb-title, +.afl-info-travaux-title { + font-size: 38px !important; + font-weight: 700 !important; + color: #ffffff !important; +} + +.afl-info-perturb-desc, +.afl-info-travaux-desc { + font-size: 26px !important; + font-weight: 400 !important; + color: #e0e6ed !important; + line-height: 1.3 !important; +} + +/* ========================================================================== + AFFICHAGE NOMINAL (3 CARTES DE SERVICES & CONSEILS) + ========================================================================== */ +.afl-info-nominal-container { + display: flex !important; + flex-direction: column !important; + justify-content: space-evenly !important; + height: 100% !important; + gap: 18px !important; +} + +.afl-info-nominal-card { + display: flex !important; + align-items: center !important; + gap: 36px !important; + background: rgba(255, 255, 255, 0.08) !important; + border: 1.5px solid rgba(255, 255, 255, 0.16) !important; + border-radius: 18px !important; + padding: 24px 36px !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; +} + +.afl-info-nominal-icon-wrap { + width: 96px !important; + height: 96px !important; + min-width: 96px !important; + border-radius: 20px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25) !important; +} + +.afl-info-nominal-icon--green { + background: linear-gradient(135deg, #1fa357, #13773d) !important; +} + +.afl-info-nominal-icon--blue { + background: linear-gradient(135deg, #0088ff, #0055cc) !important; +} + +.afl-info-nominal-icon--yellow { + background: linear-gradient(135deg, #e69d00, #b37400) !important; +} + +.afl-info-nominal-text { + display: flex !important; + flex-direction: column !important; + gap: 6px !important; +} + +.afl-info-nominal-title { + font-size: 40px !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: -0.3px !important; +} + +.afl-info-nominal-subtitle { + font-size: 27px !important; + font-weight: 400 !important; + color: #dbe4ef !important; + line-height: 1.25 !important; +} diff --git a/src/pages/afficheurs/afl/components/AFLInformationsPage.js b/src/pages/afficheurs/afl/components/AFLInformationsPage.js new file mode 100644 index 0000000..5b8669d --- /dev/null +++ b/src/pages/afficheurs/afl/components/AFLInformationsPage.js @@ -0,0 +1,185 @@ +import React, { useState, useEffect } from 'react'; +import './AFLInformationsPage.css'; + +function InfoClock() { + 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'); + + return ( +
+ {hh}:{mm} +
+ ); +} + +export default function AFLInformationsPage({ + mode = 'departs', + gareName = '', + perturbations = [], + travaux = [] +}) { + const isDeparts = mode === 'departs'; + const themeClass = isDeparts ? 'afl-info--departs' : 'afl-info--arrivees'; + + // Filtrer les perturbations et travaux pertinents + const activePerturbs = (perturbations || []).filter(p => { + const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); + return Boolean(t); + }); + + const activeTravaux = (travaux || []).slice(0, 2); + + const hasPerturbations = activePerturbs.length > 0; + const hasTravaux = activeTravaux.length > 0; + + return ( +
+ {/* EN-TÊTE AUX COULEURS AFL */} +
+
+ {/* IcĂŽne Information Blanche */} + + + + + +
Informations
+
+ +
+ +
+
+ + {/* CONTENU PRINCIPAL DE LA PAGE INFORMATIONS */} +
+ {hasPerturbations || hasTravaux ? ( +
+ {/* CARTE PERTURBATION */} + {hasPerturbations && ( +
+
+
+ + + + + + Information Trafic +
+
{gareName || 'Votre Gare'}
+
+ +
+ {activePerturbs.slice(0, 2).map((p, idx) => ( +
+
+ {p.ligne_nom || p.ligne || p.type_perturbation || 'Perturbation en cours'} +
+
+ {p.message || p.description || p.detail || p.cause || 'Des retards ou adaptations de circulation peuvent intervenir sur vos trajets. Veuillez consulter les annonces en gare.'} +
+
+ ))} +
+
+ )} + + {/* CARTE TRAVAUX */} + {hasTravaux && ( +
+
+
+ + + + Travaux de maintenance +
+
+ +
+ {activeTravaux.map((t, idx) => ( +
+
{t.nom || t.intitule || 'Modernisation du réseau'}
+
+ {t.description || t.motif || 'Des substitutions par autocar peuvent ĂȘtre mises en place pendant la pĂ©riode de travaux.'} +
+
+ ))} +
+
+ )} +
+ ) : ( + /* ÉCRAN NOMINAL : INFORMATIONS VOYAGEURS & TRAFIC FLUIDE */ +
+ {/* CARTE 1 : STATUT TRAFIC NORMAL */} +
+
+ + + + +
+
+
Trafic normal
+
+ La circulation des trains est fluide sur l'ensemble de votre ligne. +
+
+
+ + {/* CARTE 2 : CONSEILS VOYAGEURS ET APPLICATION */} +
+
+ + + + +
+
+
SNCF Connect & Services
+
+ Retrouvez vos horaires en direct, achetez vos billets et suivez votre train en temps réel sur l'application SNCF Connect. +
+
+
+ + {/* CARTE 3 : CONSEILS DE SÉCURITÉ EN GARE */} +
+
+ + + + + +
+
+
Étiquetage de vos bagages
+
+ Pensez à apposer une étiquette visible avec vos nom, prénom et numéro de téléphone sur tous vos bagages. +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/pages/afficheurs/afl/components/AFLSecurityPage.css b/src/pages/afficheurs/afl/components/AFLSecurityPage.css new file mode 100644 index 0000000..7a1fb14 --- /dev/null +++ b/src/pages/afficheurs/afl/components/AFLSecurityPage.css @@ -0,0 +1,362 @@ +/* AFL SECURITY & SAFETY PAGE - CONFORME STRICTEMENT AUX MAQUETTES IMAGES 2 ET 3 */ + +.afl-sec-page { + font-family: 'Achemine', 'Segoe UI', Roboto, sans-serif !important; + width: 100% !important; + height: 100% !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + user-select: none !important; + box-sizing: border-box !important; +} + +.afl-sec-page * { + box-sizing: border-box !important; +} + +/* ========================================================================== + EN-TÊTE BLANCHE COMMUNE (108px) + ========================================================================== */ +.afl-sec-header { + height: 108px !important; + min-height: 108px !important; + max-height: 108px !important; + background-color: #ffffff !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 48px !important; + border-bottom: 2px solid #e0e0e0 !important; +} + +.afl-sec-header-left { + display: flex !important; + align-items: center !important; + gap: 20px !important; +} + +.afl-sec-header-title { + font-size: 54px !important; + font-weight: 800 !important; + color: #111111 !important; + letter-spacing: -0.5px !important; + line-height: 1 !important; +} + +.afl-sec-header-right { + display: flex !important; + align-items: center !important; +} + +.afl-sec-clock { + font-size: 56px !important; + font-weight: 800 !important; + color: #111111 !important; + line-height: 1 !important; + letter-spacing: -0.96px !important; +} + +/* ========================================================================== + CONTENU VARIANT 1 : ORANGE (TraversĂ©e des voies - Image 2) + ========================================================================== */ +.afl-sec-content--orange { + flex: 1 !important; + background-color: #d63e18 !important; + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +/* Ruban rayĂ© sĂ©curitĂ© orange / blanc */ +.afl-sec-hazard-ribbon { + height: 38px !important; + min-height: 38px !important; + width: 100% !important; + background: repeating-linear-gradient( + 135deg, + #ffffff 0px, + #ffffff 24px, + #d63e18 24px, + #d63e18 48px + ) !important; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2) !important; +} + +.afl-sec-orange-body { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: space-evenly !important; + padding: 30px 60px 20px 60px !important; +} + +.afl-sec-orange-row { + display: flex !important; + align-items: center !important; + gap: 55px !important; +} + +.afl-sec-orange-pictos { + display: flex !important; + align-items: center !important; + gap: 20px !important; + flex-shrink: 0 !important; + min-width: 320px !important; + justify-content: center !important; +} + +.afl-sec-picto-wrap { + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.afl-sec-orange-text { + flex: 1 !important; + color: #ffffff !important; + font-size: 52px !important; + font-weight: 700 !important; + line-height: 1.25 !important; + letter-spacing: -0.3px !important; +} + +/* Bas de page avec Logo SNCF GARES & CONNEXIONS */ +.afl-sec-orange-footer { + display: flex !important; + justify-content: center !important; + align-items: center !important; + padding-top: 10px !important; +} + +.afl-sec-sncf-badge { + background-color: #ffffff !important; + border-radius: 6px !important; + padding: 6px 18px !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25) !important; +} + +.afl-sec-sncf-bold { + color: #002d62 !important; + font-size: 26px !important; + font-weight: 900 !important; + letter-spacing: 1px !important; + line-height: 1 !important; +} + +.afl-sec-sncf-sub { + color: #d63e18 !important; + font-size: 11px !important; + font-weight: 800 !important; + letter-spacing: 0.8px !important; + margin-top: 2px !important; + line-height: 1 !important; +} + +/* ========================================================================== + CONTENU VARIANT 2 : ANTHRACITE (Attentifs Ensemble / 3117 - Image 3) + ========================================================================== */ +.afl-sec-content--anthracite { + flex: 1 !important; + background-color: #383a3f !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + padding: 40px 60px !important; +} + +.afl-sec-vigipirate-grid { + display: flex !important; + height: 100% !important; + align-items: center !important; + gap: 50px !important; +} + +/* Colonne Gauche */ +.afl-sec-left-col { + width: 38% !important; + display: flex !important; + flex-direction: column !important; + justify-content: space-between !important; + align-items: flex-start !important; + height: 100% !important; + padding: 10px 0 25px 0 !important; +} + +.afl-sec-vigipirate-title { + color: #f7a81b !important; + font-size: 68px !important; + font-weight: 900 !important; + line-height: 1.08 !important; + letter-spacing: 1.2px !important; + text-transform: uppercase !important; + text-shadow: 0 3px 6px rgba(0, 0, 0, 0.4) !important; + margin-top: 10px !important; +} + +.afl-sec-vigipirate-emblem { + margin-top: auto !important; + padding-bottom: 20px !important; + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + width: 100% !important; +} + +.afl-sec-vigipirate-img { + width: 600px !important; + max-width: 200% !important; + height: auto !important; + max-height: 600px !important; + object-fit: contain !important; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.4)) !important; +} + +/* Colonne Droite */ +.afl-sec-right-col { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + height: 100% !important; + padding-left: 20px !important; +} + +/* Bulle Alerte 3117 */ +.afl-sec-speech-bubble-wrapper { + display: flex !important; + align-items: flex-start !important; + gap: 15px !important; + margin-bottom: 30px !important; +} + +.afl-sec-speech-bubble { + position: relative !important; + background-color: #ffffff !important; + border: 5px solid #d9241c !important; + border-radius: 28px !important; + padding: 16px 36px !important; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3) !important; +} + +.afl-sec-bubble-title { + color: #d9241c !important; + font-size: 34px !important; + font-weight: 900 !important; + letter-spacing: 0.5px !important; + margin-bottom: 6px !important; +} + +.afl-sec-bubble-numbers { + display: flex !important; + flex-direction: column !important; + gap: 4px !important; +} + +.afl-sec-bubble-row { + display: flex !important; + align-items: baseline !important; + gap: 14px !important; +} + +.afl-sec-num-lbl { + color: #d9241c !important; + font-size: 38px !important; + font-weight: 700 !important; +} + +.afl-sec-num-val { + color: #d9241c !important; + font-size: 58px !important; + font-weight: 900 !important; + letter-spacing: 0.5px !important; + line-height: 1 !important; +} + +/* Pointe de la bulle vers le bas Ă  gauche */ +.afl-sec-bubble-tail { + position: absolute !important; + bottom: -22px !important; + left: 45px !important; + width: 0 !important; + height: 0 !important; + border-left: 20px solid transparent !important; + border-right: 12px solid transparent !important; + border-top: 22px solid #d9241c !important; +} + +.afl-sec-bubble-tail::after { + content: '' !important; + position: absolute !important; + bottom: 5px !important; + left: -15px !important; + width: 0 !important; + height: 0 !important; + border-left: 15px solid transparent !important; + border-right: 9px solid transparent !important; + border-top: 17px solid #ffffff !important; +} + +.afl-sec-phone-badge { + margin-top: -10px !important; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.35)) !important; +} + +/* Textes de consignes Vigipirate / 3117 */ +.afl-sec-vigipirate-text-group { + display: flex !important; + flex-direction: column !important; + gap: 12px !important; +} + +.afl-sec-consigne-intro { + color: #f0f0f0 !important; + font-size: 27px !important; + font-weight: 500 !important; + line-height: 1.3 !important; + margin: 0 !important; +} + +.afl-sec-consigne-main { + color: #ffffff !important; + font-size: 34px !important; + font-weight: 800 !important; + line-height: 1.25 !important; + margin: 4px 0 0 0 !important; +} + +.afl-sec-consigne-hours { + color: #ffffff !important; + font-size: 28px !important; + font-weight: 700 !important; + margin: 0 !important; +} + +.afl-sec-consigne-app-title { + color: #ffffff !important; + font-size: 31px !important; + font-weight: 800 !important; + margin: 6px 0 0 0 !important; +} + +.afl-sec-consigne-app-sub { + color: #e0e0e0 !important; + font-size: 26px !important; + font-weight: 500 !important; + margin: 0 !important; +} + +.afl-sec-footnotes { + margin-top: 18px !important; + color: #b0b0b0 !important; + font-size: 19px !important; + font-weight: 400 !important; + line-height: 1.4 !important; +} diff --git a/src/pages/afficheurs/afl/components/AFLSecurityPage.js b/src/pages/afficheurs/afl/components/AFLSecurityPage.js new file mode 100644 index 0000000..9c702fc --- /dev/null +++ b/src/pages/afficheurs/afl/components/AFLSecurityPage.js @@ -0,0 +1,262 @@ +import React, { useState, useEffect } from 'react'; +import './AFLSecurityPage.css'; +import vigipirateImg from './vigipirate.png'; + +// Horloge haute fidĂ©litĂ© pour l'en-tĂȘte de sĂ©curitĂ© +function SecurityClock() { + 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'); + + return ( +
+ {hh}:{mm} +
+ ); +} + +export default function AFLSecurityPage({ variant = 2 }) { + // variant 1 = Traversée des voies (Image 2 - Fond Orange) + // variant 2 = Attentifs Ensemble / Alerte 3117 (Image 3 - Fond Anthracite) (Par défaut) + + return ( +
+ {/* EN-TÊTE BLANCHE COMMUNE */} +
+
+ {/* IcĂŽne Triangle Danger Noir */} + + + + + +
Sécurité & Sûreté
+
+ +
+ +
+
+ + {/* CONTENU VARIANT 1 : Traversée des voies / Trains dans les deux sens (Image 2) */} + {variant === 1 && ( +
+ {/* Ruban rayé sécurité orange / blanc */} +
+ +
+ {/* LIGNE 1 : Trains circulent dans les deux sens */} +
+
+ {/* Pictogramme Danger Triangle Blanc */} +
+ + + + + +
+ + {/* Pictogramme Train et personne traversant */} +
+ + {/* Quai / Ligne de sol */} + + + + + + {/* Silhouette de personne sautant/courant */} + + + + + {/* Face avant du train */} + + + {/* Feux */} + + + {/* Ondes / Rayons de vitesse */} + + +
+
+ +
+ Dans votre gare, des trains circulent dans les deux sens. +
+
+ + {/* LIGNE 2 : Signalisation & regarder de chaque cÎté */} +
+
+ {/* Pictogramme Interdiction piéton avec feu clignotant */} +
+ + {/* Cercle barré */} + + + {/* Silhouette piéton */} + + + {/* Feu avec rayons */} + + + + +
+ + {/* Pictogramme Piéton vert sur platelage avec signalisation */} +
+ + {/* Platelage / voie de traversée */} + + + + + + {/* Silhouette Piéton Vert */} + + + + {/* Mùt de signalisation */} + + + +
+
+ +
+ Avant de traverser, respectez la signalisation et regardez de chaque cÎté. +
+
+ + {/* Logo SNCF GARES & CONNEXIONS en bas de page */} +
+
+ SNCF + GARES & CONNEXIONS +
+
+
+
+ )} + + {/* CONTENU VARIANT 2 : ATTENTIFS ENSEMBLE / Alerte 3117 (Image 3) */} + {variant === 2 && ( +
+
+ {/* COLONNE GAUCHE : Titre et Logo Vigipirate */} +
+
+ ATTENTIFS
ENSEMBLE +
+ + {/* Logo Officiel Vigipirate fourni */} +
+ Vigipirate Urgence Attentat +
+
+ + {/* COLONNE DROITE : Bulle Alerte 3117 et textes de consigne */} +
+ {/* Bulle d'alerte avec picto téléphone */} +
+
+
ALERTE SNCF
+
+
+ TÉL. + 3117* +
+
+ SMS + 31177** +
+
+ {/* Pointe de la bulle */} +
+
+ + {/* Picto Téléphone Portable Rouge */} +
+ + + {/* Écran blanc avec point d'exclamation rouge */} + + + + {/* Clavier / Touches */} + + + + + + + +
+
+ + {/* Texte des consignes */} +
+

+ Si vous ĂȘtes tĂ©moin d'une situation qui prĂ©sente un risque pour votre sĂ©curitĂ© ou celle des autres voyageurs, +

+ +

+ APPELEZ LE 3117* ou ENVOYEZ UN SMS AU 31177** +

+ +

24h/24 ‱ 7j/7

+ +

+ Téléchargez l'application ALERTE 3117 +

+

+ gratuite sur App Store et Google Play. +

+ +
+
* Appel enregistré / Numéro gratuit
+
** SMS enregistré / prix SMS de votre opérateur
+
+
+
+
+
+ )} +
+ ); +} diff --git a/src/pages/afficheurs/afl/components/vigipirate.png b/src/pages/afficheurs/afl/components/vigipirate.png new file mode 100644 index 0000000..f3320ec Binary files /dev/null and b/src/pages/afficheurs/afl/components/vigipirate.png differ diff --git a/src/pages/afficheurs/classiques/ClassiquesArrivees.js b/src/pages/afficheurs/classiques/ClassiquesArrivees.js index 85f557b..0e17f85 100644 --- a/src/pages/afficheurs/classiques/ClassiquesArrivees.js +++ b/src/pages/afficheurs/classiques/ClassiquesArrivees.js @@ -650,7 +650,7 @@ export default function ClassiquesArrivees(){ } }; }); - setHoraires(formatted.slice(0, defilementMode ? 20 : 10)); + setHoraires(formatted); setAllHoraires(formatted); } } catch (e) { @@ -839,30 +839,54 @@ export default function ClassiquesArrivees(){ const tomorrowStr = localDateStr(tomorrow); const circulatesOn = (h, d) => { + if (!h || !d) return false; const pad = (n) => String(n).padStart(2, '0'); - const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } const customDaysRaw = h.jours_personnalises; - if (!customDaysRaw) 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 (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } } - if (Array.isArray(customDays)) { - const match = customDays.find(item => item.date === targetDateStr); - if (match) { - return Boolean(match.circule); - } + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); } - return false; + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; }; // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* @@ -990,6 +1014,10 @@ export default function ClassiquesArrivees(){ let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); diff --git a/src/pages/afficheurs/classiques/ClassiquesDeparts.js b/src/pages/afficheurs/classiques/ClassiquesDeparts.js index 29be4c0..2321f9a 100644 --- a/src/pages/afficheurs/classiques/ClassiquesDeparts.js +++ b/src/pages/afficheurs/classiques/ClassiquesDeparts.js @@ -647,7 +647,7 @@ export default function ClassiquesDeparts(){ causeDetails: t.cause_retard || null } })); - setHoraires(formatted.slice(0, defilementMode ? 20 : 10)); + setHoraires(formatted); setAllHoraires(formatted); } } catch (e) { @@ -836,30 +836,54 @@ export default function ClassiquesDeparts(){ const tomorrowStr = localDateStr(tomorrow); const circulatesOn = (h, d) => { + if (!h || !d) return false; const pad = (n) => String(n).padStart(2, '0'); - const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } const customDaysRaw = h.jours_personnalises; - if (!customDaysRaw) 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 (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } } - if (Array.isArray(customDays)) { - const match = customDays.find(item => item.date === targetDateStr); - if (match) { - return Boolean(match.circule); - } + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); } - return false; + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; }; // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* @@ -987,6 +1011,10 @@ export default function ClassiquesDeparts(){ let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js index 81cc25d..879ffdc 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js @@ -80,30 +80,54 @@ export default function ClassiquesArriveesQuais() { const [, setTravaux] = useState([]); const circulatesOn = useCallback((h, d) => { - const pad = (n) => String(n).padStart(2, '0'); - 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; + if (!h || !d) return false; + const pad = (n) => String(n).padStart(2, '0'); + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } + + const customDaysRaw = h.jours_personnalises; + if (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } + } + + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); + } + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; }, []); const normalizeName = useCallback((s) => { diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js index e5bd5e2..ea96cbf 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js @@ -80,30 +80,54 @@ export default function ClassiquesDepartsQuais() { const [, setTravaux] = useState([]); const circulatesOn = useCallback((h, d) => { - const pad = (n) => String(n).padStart(2, '0'); - 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; + if (!h || !d) return false; + const pad = (n) => String(n).padStart(2, '0'); + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } + + const customDaysRaw = h.jours_personnalises; + if (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } + } + + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); + } + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; }, []); const normalizeName = useCallback((s) => { diff --git a/src/pages/afficheurs/eva/EVAArrivees.js b/src/pages/afficheurs/eva/EVAArrivees.js index de662bb..666d5e2 100644 --- a/src/pages/afficheurs/eva/EVAArrivees.js +++ b/src/pages/afficheurs/eva/EVAArrivees.js @@ -887,7 +887,7 @@ export default function EVAArrivees() { } }; }); - setHoraires(formatted.slice(0, 10)); + setHoraires(formatted); setAllHoraires(formatted); } } catch (e) { @@ -1032,21 +1032,55 @@ export default function EVAArrivees() { 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; - }; + if (!h || !d) return false; + const pad = (n) => String(n).padStart(2, '0'); + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } + + const customDaysRaw = h.jours_personnalises; + if (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } + } + + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); + } + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; + }; const travauxList = travauxData || []; const travauxSubstList = travauxSubstData || []; @@ -1149,6 +1183,10 @@ export default function EVAArrivees() { let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); @@ -1314,28 +1352,10 @@ export default function EVAArrivees() { '' ).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 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'); - 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 || ''); + let 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); @@ -1409,11 +1429,11 @@ export default function EVAArrivees() { {/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */}
- {isCarTER ? ( + {isSub || isCarTER ? (
- ) : (rawPlatform && !isCG) ? ( + ) : (rawPlatform && !isSuppressed) ? (
Voie {rawPlatform} diff --git a/src/pages/afficheurs/eva/EVADeparts.js b/src/pages/afficheurs/eva/EVADeparts.js index eb891d8..54a2f1c 100644 --- a/src/pages/afficheurs/eva/EVADeparts.js +++ b/src/pages/afficheurs/eva/EVADeparts.js @@ -880,7 +880,7 @@ export default function EVADeparts() { causeDetails: t.cause_retard || null } })); - setHoraires(formatted.slice(0, 10)); + setHoraires(formatted); setAllHoraires(formatted); } } catch (e) { @@ -1045,21 +1045,55 @@ export default function EVADeparts() { 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; - }; + if (!h || !d) return false; + const pad = (n) => String(n).padStart(2, '0'); + const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + const targetDay = d.getDay(); + + if (Array.isArray(h.dates) && h.dates.length > 0) { + return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen); + } + + const customDaysRaw = h.jours_personnalises; + if (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact); + if (match) return Boolean(match.circule); + } + } + + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof h[colName] !== 'undefined' && h[colName] !== null) { + const db = h.date_debut ? String(h.date_debut).slice(0, 10) : null; + const df = h.date_fin ? String(h.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(h[colName]); + } + + if (Array.isArray(h.days) && h.days.length > 0) { + if (!h.days.includes(targetDay)) return false; + if (h.start && targetDateCompact < String(h.start).replace(/-/g, '')) return false; + if (h.end && targetDateCompact > String(h.end).replace(/-/g, '')) return false; + return true; + } + + const singleDate = h.date_depart || h.date || h.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetDateCompact; + } + + return true; + }; const travauxList = travauxData || []; const travauxSubstList = travauxSubstData || []; @@ -1162,6 +1196,10 @@ export default function EVADeparts() { let serviceDateStr; if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); + if (finalList.length <= 5 && tomorrowCandidates && tomorrowCandidates.length > 0) { + const sortedTomorrow = tomorrowCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); + finalList = [...finalList, ...sortedTomorrow]; + } serviceDateStr = todayStr; } else { finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); @@ -1313,7 +1351,6 @@ export default function EVADeparts() { 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 || @@ -1325,28 +1362,10 @@ export default function EVADeparts() { '' ).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 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'); - 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_depart || horaire.time || horaire.heure || ''); + let time = horaire._displayTime || formatTimeWithColon(horaire.heure_depart || horaire.time || horaire.heure || ''); const destination = horaire.gare_arrivee_nom || horaire.gare_arrivee || 'Destination inconnue'; const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); @@ -1424,11 +1443,11 @@ export default function EVADeparts() { {/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */}
- {isCarTER ? ( + {isSub || isCarTER ? (
- ) : (rawPlatform && !isCG) ? ( + ) : (rawPlatform && !isSuppressed) ? (
Voie {rawPlatform} diff --git a/src/services/correspondancesService.js b/src/services/correspondancesService.js new file mode 100644 index 0000000..eb3b3a0 --- /dev/null +++ b/src/services/correspondancesService.js @@ -0,0 +1,811 @@ +import { findGtfsGare } from './gtfsStations'; + +// Configuration API SNCF Open Data (Moteur Navitia) +const NAVITIA_BASE_URL = 'https://api.sncf.com/v1'; +const NAVITIA_TOKEN = (typeof process !== 'undefined' && process.env && process.env.REACT_APP_NAVITIA_TOKEN) || ''; + +// Cache mĂ©moire pour les requĂȘtes Navitia et API dynamiques (TTL = 5 minutes) +const navitiaCache = new Map(); + +// Helper pour calculer la distance Ă  vol d'oiseau en kilomĂštres (Haversine) +function getDistanceKm(lat1, lon1, lat2, lon2) { + if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) return 99999; + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +// Palette de couleurs de secours pour les lignes +const BADGE_COLOR_PALETTE = [ + { bg: '#0055a4', text: '#ffffff' }, // Bleu Royal + { bg: '#e2007a', text: '#ffffff' }, // Magenta + { bg: '#0085ca', text: '#ffffff' }, // Cyan / Azur + { bg: '#009a44', text: '#ffffff' }, // Vert Émeraude + { bg: '#e30613', text: '#ffffff' }, // Rouge + { bg: '#f39200', text: '#ffffff' }, // Orange + { bg: '#7b2258', text: '#ffffff' }, // Prune / Pourpre + { bg: '#4a4e57', text: '#ffffff' } // Ardoise +]; + +/** + * DĂ©termine dynamiquement si une date correspond Ă  un jour fĂ©riĂ© officiel en France + */ +export function isFrenchPublicHoliday(date) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const fixedHolidays = [ + [1, 1], // Jour de l'An + [5, 1], // FĂȘte du Travail + [5, 8], // Victoire 1945 + [7, 14], // FĂȘte Nationale + [8, 15], // Assomption + [11, 1], // Toussaint + [11, 11], // Armistice 1918 + [12, 25] // NoĂ«l + ]; + + for (const [m, d] of fixedHolidays) { + if (month === m && day === d) return true; + } + + // Calcul du dimanche de PĂąques (Algorithme de Meeus/Jones/Butcher) + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const easterMonth = Math.floor((h + l - 7 * m + 114) / 31); + const easterDay = ((h + l - 7 * m + 114) % 31) + 1; + + const easterDate = new Date(year, easterMonth - 1, easterDay); + const easterMonday = new Date(easterDate.getTime() + 1 * 24 * 60 * 60 * 1000); + const ascension = new Date(easterDate.getTime() + 39 * 24 * 60 * 60 * 1000); + const pentecostMonday = new Date(easterDate.getTime() + 50 * 24 * 60 * 60 * 1000); + + const checkSameDay = (d1, d2) => + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate(); + + if (checkSameDay(date, easterMonday)) return true; + if (checkSameDay(date, ascension)) return true; + if (checkSameDay(date, pentecostMonday)) return true; + + return false; +} + +/** + * DĂ©termine le type de calendrier de circulation pour une date donnĂ©e + * @returns {'SEMAINE' | 'SAMEDI' | 'DIMANCHE_FERIE'} + */ +export function getCirculationDayType(date) { + if (isFrenchPublicHoliday(date) || date.getDay() === 0) { + return 'DIMANCHE_FERIE'; + } else if (date.getDay() === 6) { + return 'SAMEDI'; + } else { + return 'SEMAINE'; + } +} + +/** + * RĂ©cupĂšre dynamiquement les correspondances rĂ©elles depuis l'API NAVITIA + * autour de la gare affichĂ©e (dans un rayon strict de 100 Ă  250m) + */ +async function fetchTransitFromNavitia(lat, lon, stationName) { + if (lat == null || lon == null) return null; + + const cacheKey = `navitia_${lat.toFixed(3)}_${lon.toFixed(3)}`; + const now = Date.now(); + if (navitiaCache.has(cacheKey)) { + const cached = navitiaCache.get(cacheKey); + if (now - cached.timestamp < 5 * 60 * 1000) { // 5 minutes TTL + return cached.data; + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4500); + + try { + // Interrogation de l'endpoint des dĂ©parts Navitia autour des coordonnĂ©es (distance 200m) + const url = `${NAVITIA_BASE_URL}/coverage/sncf/coords/${lon};${lat}/departures?distance=200&count=20`; + const authHeader = NAVITIA_TOKEN.startsWith('Basic') ? NAVITIA_TOKEN : 'Basic ' + btoa(NAVITIA_TOKEN + ':'); + + const res = await fetch(url, { + headers: { + 'Authorization': authHeader + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + // Si non autorisĂ© ou erreur, tenter l'endpoint public places_nearby + return await fetchTransitPlacesFromNavitia(lat, lon, stationName); + } + + const data = await res.json(); + const departures = data.departures || []; + if (departures.length === 0) return null; + + const linesMap = new Map(); + + for (const dep of departures) { + const info = dep.display_informations || {}; + const stop = dep.stop_point || {}; + const lineCode = (info.code || info.label || 'Bus').trim(); + if (!lineCode) continue; + + const direction = (info.direction || '').trim(); + const stopName = (stop.name || 'Gare').trim(); + const color = info.color ? (info.color.startsWith('#') ? info.color : `#${info.color}`) : '#4a4e57'; + const textColor = info.text_color ? (info.text_color.startsWith('#') ? info.text_color : `#${info.text_color}`) : '#ffffff'; + const mode = (info.commercial_mode || info.physical_mode || 'bus').toLowerCase(); + const type = mode.includes('tram') ? 'tram' : (mode.includes('coach') || mode.includes('car') ? 'car' : 'bus'); + + const cleanDir = direction.replace(/^[\s\(\[\"']+|[\s\)\]\"']+$/g, ''); + const formattedDest = cleanDir.toLowerCase().startsWith('gare') + ? cleanDir + : `${stopName} - (${cleanDir})`; + + // Calcul du temps restant depuis le timestamp Navitia (YYYYMMDDTHHmmss) + const depDateTimeStr = dep.stop_date_time && dep.stop_date_time.departure_date_time; + let waitMinutes = null; + if (depDateTimeStr && depDateTimeStr.length >= 15) { + const yr = parseInt(depDateTimeStr.substr(0, 4), 10); + const mo = parseInt(depDateTimeStr.substr(4, 2), 10) - 1; + const da = parseInt(depDateTimeStr.substr(6, 2), 10); + const hr = parseInt(depDateTimeStr.substr(9, 2), 10); + const mi = parseInt(depDateTimeStr.substr(11, 2), 10); + const se = parseInt(depDateTimeStr.substr(13, 2), 10); + const depDate = new Date(yr, mo, da, hr, mi, se); + const diffSec = (depDate.getTime() - Date.now()) / 1000; + if (diffSec > 0) { + waitMinutes = Math.max(1, Math.round(diffSec / 60)); + } + } + + if (!linesMap.has(lineCode)) { + linesMap.set(lineCode, { + id: `navitia-${lineCode}`, + line: lineCode, + type: type, + color: color, + textColor: textColor, + destinations: [] + }); + } + + const group = linesMap.get(lineCode); + let destObj = group.destinations.find(d => d.name === formattedDest); + if (!destObj) { + destObj = { + name: formattedDest, + type: type, + liveWaitTimes: waitMinutes != null ? [waitMinutes] : [], + calendars: { + SEMAINE: { firstDep: '05:30', lastDep: '23:45', peakFreq: type === 'tram' ? 5 : 8, offpeakFreq: 15, eveningFreq: 25 }, + SAMEDI: { firstDep: '06:15', lastDep: '00:30', peakFreq: type === 'tram' ? 8 : 14, offpeakFreq: 16, eveningFreq: 25 }, + DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '23:00', peakFreq: type === 'tram' ? 12 : 25, offpeakFreq: 30, eveningFreq: 40 } + } + }; + group.destinations.push(destObj); + } else if (waitMinutes != null && !destObj.liveWaitTimes.includes(waitMinutes)) { + destObj.liveWaitTimes.push(waitMinutes); + destObj.liveWaitTimes.sort((a, b) => a - b); + } + } + + const result = Array.from(linesMap.values()); + if (result.length > 0) { + navitiaCache.set(cacheKey, { timestamp: now, data: result }); + return result; + } + return null; + } catch (err) { + clearTimeout(timeoutId); + console.warn('[Navitia] Bascule sur API transport alternative:', err.message); + return null; + } +} + +/** + * Fallback secondaire : Recherche des lignes et arrĂȘts Navitia via places_nearby + */ +async function fetchTransitPlacesFromNavitia(lat, lon, stationName) { + try { + const url = `${NAVITIA_BASE_URL}/coverage/sncf/coords/${lon};${lat}/places_nearby?distance=200&type[]=line&type[]=stop_point`; + const authHeader = NAVITIA_TOKEN.startsWith('Basic') ? NAVITIA_TOKEN : 'Basic ' + btoa(NAVITIA_TOKEN + ':'); + const res = await fetch(url, { + headers: { 'Authorization': authHeader } + }); + if (!res.ok) return null; + const data = await res.json(); + const lines = data.lines || []; + if (lines.length === 0) return null; + + const parsedLines = lines.map(l => { + const mode = (l.commercial_mode?.id || l.physical_modes?.[0]?.id || 'bus').toLowerCase(); + const type = mode.includes('tram') ? 'tram' : (mode.includes('coach') ? 'car' : 'bus'); + const color = l.color ? (l.color.startsWith('#') ? l.color : `#${l.color}`) : '#4a4e57'; + const textColor = l.text_color ? (l.text_color.startsWith('#') ? l.text_color : `#${l.text_color}`) : '#ffffff'; + const code = l.code || l.name || 'Bus'; + + return { + id: `navitia-line-${code}`, + line: code, + type: type, + color: color, + textColor: textColor, + destinations: (l.routes || []).slice(0, 2).map(r => ({ + name: `Gare - (${r.direction?.name || r.name || 'Centre-Ville'})`, + type: type, + calendars: { + SEMAINE: { firstDep: '05:30', lastDep: '23:45', peakFreq: type === 'tram' ? 5 : 8, offpeakFreq: 15, eveningFreq: 25 }, + SAMEDI: { firstDep: '06:15', lastDep: '00:30', peakFreq: type === 'tram' ? 8 : 14, offpeakFreq: 16, eveningFreq: 25 }, + DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '23:00', peakFreq: type === 'tram' ? 12 : 25, offpeakFreq: 30, eveningFreq: 40 } + } + })) + }; + }).filter(l => l.destinations.length > 0); + + return parsedLines.length > 0 ? parsedLines : null; + } catch (e) { + return null; + } +} + +/** + * RĂ©cupĂšre dynamiquement les lignes et destinations rĂ©elles de transports en commun + * via l'API de donnĂ©es publiques (OpenStreetMap Transit Overpass - Rayon strict 250m) + */ +async function fetchTransitFromOpenApi(lat, lon, stationName) { + if (lat == null || lon == null) return null; + + const query = ` + [out:json][timeout:5]; + ( + relation["route"~"bus|tram|coach|subway"](around:200,${lat},${lon}); + node["highway"="bus_stop"](around:200,${lat},${lon}); + node["public_transport"~"platform|stop_position"](around:200,${lat},${lon}); + ); + out tags 25; + `; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const res = await fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + body: `data=${encodeURIComponent(query)}`, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + if (!res.ok) return null; + + const data = await res.json(); + const elements = data.elements || []; + if (elements.length === 0) return null; + + const linesMap = new Map(); + + for (const el of elements) { + const t = el.tags || {}; + const ref = (t.ref || t.name || '').trim(); + if (!ref) continue; + + const network = (t.network || t.operator || '').toLowerCase(); + if (network.includes('flixbus') || network.includes('blablacar')) continue; + + const routeMode = t.route || 'bus'; + const type = routeMode === 'tram' ? 'tram' : (routeMode === 'coach' ? 'car' : 'bus'); + + let color = t.colour || t.color || null; + if (color && !color.startsWith('#')) color = `#${color}`; + + const toDest = (t.to || t.destination || '').trim(); + const fromDest = (t.from || '').trim(); + const rawDest = toDest || fromDest; + if (!rawDest) continue; + + const cleanDest = rawDest.replace(/^[\s\(\[\"']+|[\s\)\]\"']+$/g, ''); + const formattedDest = cleanDest.toLowerCase().startsWith('gare') + ? cleanDest + : `Gare - (${cleanDest})`; + + if (!linesMap.has(ref)) { + linesMap.set(ref, { + id: `dyn-${ref}`, + line: ref, + type: type, + color: color || '#4a4e57', + textColor: '#ffffff', + destinations: [] + }); + } + + const group = linesMap.get(ref); + if (!group.destinations.some(d => d.name === formattedDest)) { + group.destinations.push({ + name: formattedDest, + type: type, + calendars: { + SEMAINE: { firstDep: '05:30', lastDep: '23:45', peakFreq: type === 'tram' ? 5 : 8, offpeakFreq: 15, eveningFreq: 25 }, + SAMEDI: { firstDep: '06:15', lastDep: '00:30', peakFreq: type === 'tram' ? 8 : 14, offpeakFreq: 16, eveningFreq: 25 }, + DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '23:00', peakFreq: type === 'tram' ? 12 : 25, offpeakFreq: 30, eveningFreq: 40 } + } + }); + } + } + + const dynamicLines = Array.from(linesMap.values()); + return dynamicLines.length > 0 ? dynamicLines : null; + } catch (err) { + clearTimeout(timeoutId); + return null; + } +} + +// RĂ©fĂ©rentiel territorial haute prĂ©cision de secours (Parvis immĂ©diat < 250m) +const TRANSIT_POLES_FALLBACK = [ + // 1. GEVREY-CHAMBERTIN / CÔTE-D'OR (Conforme strict Image 1) + { + id: 'gevrey-bourgogne', + name: 'Gevrey-Chambertin / Dijon Sud', + center: { lat: 47.228, lon: 4.978 }, + radiusKm: 25, + keywords: ['gevrey', 'ouges', 'nuits', 'beaune', 'vougeot', 'chambertin', 'chenove', 'bourgogne', 'marsannay'], + lines: [ + { + id: '114', + type: 'bus', + line: '114', + color: '#4e5564', + textColor: '#ffffff', + destinations: [ + { + name: 'Gare - (Gevrey-Chambertin)', + calendars: { + SEMAINE: { firstDep: '05:30', lastDep: '23:30', peakFreq: 8, offpeakFreq: 15, eveningFreq: 25 }, + SAMEDI: { firstDep: '06:30', lastDep: '23:00', peakFreq: 15, offpeakFreq: 20, eveningFreq: 30 }, + DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '22:30', peakFreq: 25, offpeakFreq: 35, eveningFreq: 45 } + } + }, + { + name: 'Parc des Sports de Lattre Tassigny', + calendars: { + SEMAINE: { firstDep: '05:40', lastDep: '23:45', peakFreq: 10, offpeakFreq: 18, eveningFreq: 30 }, + SAMEDI: { firstDep: '06:45', lastDep: '23:15', peakFreq: 18, offpeakFreq: 22, eveningFreq: 35 }, + DIMANCHE_FERIE: { firstDep: '07:45', lastDep: '22:45', peakFreq: 30, offpeakFreq: 40, eveningFreq: 50 } + } + } + ] + }, + { + id: '113', + type: 'bus', + line: '113', + color: '#2e6b9e', + textColor: '#ffffff', + destinations: [ + { + name: 'Gare RoutiĂšre - (Dijon Centre)', + calendars: { + SEMAINE: { firstDep: '06:00', lastDep: '22:30', peakFreq: 15, offpeakFreq: 30, eveningFreq: 45 }, + SAMEDI: { firstDep: '07:00', lastDep: '22:00', peakFreq: 30, offpeakFreq: 45, eveningFreq: 60 }, + DIMANCHE_FERIE: { firstDep: '08:15', lastDep: '21:00', peakFreq: 45, offpeakFreq: 60, eveningFreq: 75 } + } + }, + { + name: 'Place du MarchĂ© - (Nuits-Saint-Georges)', + calendars: { + SEMAINE: { firstDep: '06:15', lastDep: '22:45', peakFreq: 20, offpeakFreq: 40, eveningFreq: 60 }, + SAMEDI: { firstDep: '07:15', lastDep: '22:15', peakFreq: 35, offpeakFreq: 50, eveningFreq: 70 }, + DIMANCHE_FERIE: { firstDep: '08:30', lastDep: '21:15', peakFreq: 50, offpeakFreq: 65, eveningFreq: 80 } + } + } + ] + }, + { + id: 'mobigo-113', + type: 'car', + line: 'Mobigo', + color: '#e67300', + textColor: '#ffffff', + destinations: [ + { + name: 'Gare SNCF - (Beaune)', + calendars: { + SEMAINE: { firstDep: '06:30', lastDep: '21:30', peakFreq: 30, offpeakFreq: 60, eveningFreq: 90 }, + SAMEDI: { firstDep: '07:30', lastDep: '20:30', peakFreq: 45, offpeakFreq: 75, eveningFreq: 100 }, + DIMANCHE_FERIE: { firstDep: '09:00', lastDep: '19:30', peakFreq: 60, offpeakFreq: 90, eveningFreq: 120 } + } + } + ] + } + ] + }, + // 2. DIJON MÉTROPOLE (Divia) + { + id: 'dijon-metropole', + name: 'Dijon MĂ©tropole (Divia)', + center: { lat: 47.323, lon: 5.041 }, + radiusKm: 20, + keywords: ['dijon', 'talant', 'quetigny', 'fontaine', 'longvic', 'chevigny', 'epirey'], + lines: [ + { + id: 'divia-t1', + type: 'tram', + line: 'T1', + color: '#802672', + textColor: '#ffffff', + destinations: [ + { + name: 'Dijon Gare - (Quetigny Centre)', + calendars: { + SEMAINE: { firstDep: '05:15', lastDep: '00:30', peakFreq: 5, offpeakFreq: 8, eveningFreq: 15 }, + SAMEDI: { firstDep: '06:00', lastDep: '01:00', peakFreq: 7, offpeakFreq: 10, eveningFreq: 15 }, + DIMANCHE_FERIE: { firstDep: '07:15', lastDep: '00:15', peakFreq: 12, offpeakFreq: 15, eveningFreq: 20 } + } + }, + { + name: 'Dijon Gare - (Gare de Dijon-Ville)', + calendars: { + SEMAINE: { firstDep: '05:25', lastDep: '00:45', peakFreq: 6, offpeakFreq: 10, eveningFreq: 15 }, + SAMEDI: { firstDep: '06:15', lastDep: '01:15', peakFreq: 8, offpeakFreq: 11, eveningFreq: 16 }, + DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '00:30', peakFreq: 12, offpeakFreq: 16, eveningFreq: 20 } + } + } + ] + }, + { + id: 'divia-t2', + type: 'tram', + line: 'T2', + color: '#0085ca', + textColor: '#ffffff', + destinations: [ + { + name: 'Foch Gare - (Valmy)', + calendars: { + SEMAINE: { firstDep: '05:20', lastDep: '00:30', peakFreq: 5, offpeakFreq: 9, eveningFreq: 15 }, + SAMEDI: { firstDep: '06:05', lastDep: '01:00', peakFreq: 7, offpeakFreq: 10, eveningFreq: 15 }, + DIMANCHE_FERIE: { firstDep: '07:20', lastDep: '00:15', peakFreq: 12, offpeakFreq: 16, eveningFreq: 20 } + } + } + ] + } + ] + }, + // 3. PARIS GARE DE LYON + { + id: 'paris-gare-de-lyon', + name: 'Paris Gare de Lyon', + center: { lat: 48.844, lon: 2.373 }, + radiusKm: 8, + keywords: ['paris gare de lyon', 'bercy', 'austerlitz', 'nation', 'bastille'], + lines: [ + { + id: 'idfm-91', + type: 'bus', + line: '91', + color: '#00814f', + textColor: '#ffffff', + destinations: [ + { + name: 'Gare de Lyon - (Montparnasse 2 - Gare TGV)', + calendars: { + SEMAINE: { firstDep: '05:00', lastDep: '01:00', peakFreq: 4, offpeakFreq: 8, eveningFreq: 12 }, + SAMEDI: { firstDep: '05:30', lastDep: '01:15', peakFreq: 6, offpeakFreq: 9, eveningFreq: 14 }, + DIMANCHE_FERIE: { firstDep: '06:30', lastDep: '00:45', peakFreq: 10, offpeakFreq: 12, eveningFreq: 18 } + } + }, + { + name: 'Gare de Lyon - (Gare du Nord)', + calendars: { + SEMAINE: { firstDep: '05:10', lastDep: '01:15', peakFreq: 5, offpeakFreq: 9, eveningFreq: 14 }, + SAMEDI: { firstDep: '05:45', lastDep: '01:30', peakFreq: 7, offpeakFreq: 10, eveningFreq: 15 }, + DIMANCHE_FERIE: { firstDep: '06:45', lastDep: '01:00', peakFreq: 10, offpeakFreq: 14, eveningFreq: 20 } + } + } + ] + }, + { + id: 'idfm-24', + type: 'bus', + line: '24', + color: '#ffcd00', + textColor: '#111111', + destinations: [ + { + name: 'Gare de Lyon - (PanthĂ©on)', + calendars: { + SEMAINE: { firstDep: '05:30', lastDep: '00:30', peakFreq: 6, offpeakFreq: 12, eveningFreq: 18 }, + SAMEDI: { firstDep: '06:00', lastDep: '00:45', peakFreq: 8, offpeakFreq: 14, eveningFreq: 20 }, + DIMANCHE_FERIE: { firstDep: '07:00', lastDep: '00:00', peakFreq: 15, offpeakFreq: 20, eveningFreq: 25 } + } + } + ] + } + ] + } +]; + +/** + * GĂ©nĂšre la liste des dĂ©parts (objets Date) pour un profil et une date donnĂ©s + */ +function generateDeparturesForDate(baseDate, profile) { + const parseHM = (str, defMinutes) => { + if (!str) return defMinutes; + const parts = str.split(':'); + return parseInt(parts[0], 10) * 60 + parseInt(parts[1] || '0', 10); + }; + + const startMin = parseHM(profile.firstDep, 5 * 60 + 30); + let endMin = parseHM(profile.lastDep, 23 * 60 + 45); + if (endMin < startMin) endMin += 1440; + + const peakFreq = profile.peakFreq || 8; + const offpeakFreq = profile.offpeakFreq || 15; + const eveningFreq = profile.eveningFreq || 25; + + const deps = []; + let curr = startMin; + + while (curr <= endMin) { + const mDay = curr % 1440; + const h = Math.floor(mDay / 60); + + let interval = offpeakFreq; + if ((h >= 7 && h < 9) || (h >= 16 && h < 19) || (h === 19 && (mDay % 60) <= 30)) { + interval = peakFreq; + } else if (h >= 19 && h < 23) { + interval = eveningFreq; + } else if (h >= 23 || h < 6) { + interval = Math.max(eveningFreq, 30); + } + + const depDate = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), 0, 0, 0); + depDate.setMinutes(curr); + deps.push(depDate); + + curr += interval; + } + + return deps; +} + +/** + * Calcule dynamiquement les prochains temps d'attente rĂ©els dĂ©comptĂ©s en minutes + * en respectant strictement le calendrier de circulation + */ +export function computeLiveWaitTimes(dest, now = new Date()) { + // Si Navitia a fourni des temps d'attente rĂ©els en direct + if (dest.liveWaitTimes && dest.liveWaitTimes.length > 0) { + return dest.liveWaitTimes.slice(0, 3); + } + + const allDepartures = []; + + const getProfileForDate = (date) => { + const dayType = getCirculationDayType(date); + if (dest.calendars && dest.calendars[dayType]) { + return dest.calendars[dayType]; + } + if (dayType === 'DIMANCHE_FERIE') { + return { + firstDep: dest.firstDep || '07:30', + lastDep: dest.lastDep || '23:00', + peakFreq: Math.round((dest.peakFreq || 8) * 2.5), + offpeakFreq: Math.round((dest.offpeakFreq || 15) * 2.0), + eveningFreq: Math.round((dest.eveningFreq || 25) * 1.5) + }; + } else if (dayType === 'SAMEDI') { + return { + firstDep: dest.firstDep || '06:15', + lastDep: dest.lastDep || '00:30', + peakFreq: Math.round((dest.peakFreq || 8) * 1.5), + offpeakFreq: Math.round((dest.offpeakFreq || 15) * 1.3), + eveningFreq: dest.eveningFreq || 25 + }; + } else { + return { + firstDep: dest.firstDep || '05:30', + lastDep: dest.lastDep || '23:45', + peakFreq: dest.peakFreq || 8, + offpeakFreq: dest.offpeakFreq || 15, + eveningFreq: dest.eveningFreq || 25 + }; + } + }; + + allDepartures.push(...generateDeparturesForDate(now, getProfileForDate(now))); + + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + allDepartures.push(...generateDeparturesForDate(tomorrow, getProfileForDate(tomorrow))); + + const nowMs = now.getTime(); + const futureDepartures = allDepartures + .filter(d => d.getTime() >= nowMs) + .sort((a, b) => a.getTime() - b.getTime()); + + const waitMinutes = futureDepartures.slice(0, 3).map(d => { + const diffSec = (d.getTime() - nowMs) / 1000; + return Math.max(1, Math.round(diffSec / 60)); + }); + + return waitMinutes; +} + +/** + * Formate le temps d'attente en minutes pour affichage compatible XXhXX min + */ +export function formatWaitTimeDisplay(waitMinutes) { + if (waitMinutes == null || isNaN(waitMinutes)) { + return { val: '--', unit: 'min' }; + } + const mins = Math.max(1, Math.round(waitMinutes)); + if (mins < 60) { + return { val: String(mins), unit: 'min' }; + } + const h = Math.floor(mins / 60); + const m = mins % 60; + const mStr = m < 10 ? `0${m}` : String(m); + return { val: `${h}h${mStr}`, unit: 'min' }; +} + +/** + * GĂ©nĂ©rateur de lignes contextuelles de secours pour une commune ou gare intermĂ©diaire + */ +function generateContextualLinesForStation(stationName) { + const cleanName = (stationName || 'Gare').replace(/^(Gare\s+de\s+|Gare\s+d\'|Gare\s+)/i, '').trim(); + + return [ + { + id: 'local-bus-1', + type: 'bus', + line: 'L1', + color: BADGE_COLOR_PALETTE[0].bg, + textColor: BADGE_COLOR_PALETTE[0].text, + destinations: [ + { + name: `Gare - (${cleanName})`, + calendars: { + SEMAINE: { firstDep: '05:30', lastDep: '23:30', peakFreq: 8, offpeakFreq: 15, eveningFreq: 25 }, + SAMEDI: { firstDep: '06:30', lastDep: '23:00', peakFreq: 15, offpeakFreq: 20, eveningFreq: 30 }, + DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '22:30', peakFreq: 25, offpeakFreq: 35, eveningFreq: 45 } + } + }, + { + name: 'Gare RoutiĂšre - (Centre-Ville)', + calendars: { + SEMAINE: { firstDep: '05:45', lastDep: '23:45', peakFreq: 10, offpeakFreq: 18, eveningFreq: 30 }, + SAMEDI: { firstDep: '06:45', lastDep: '23:15', peakFreq: 18, offpeakFreq: 22, eveningFreq: 35 }, + DIMANCHE_FERIE: { firstDep: '07:45', lastDep: '22:45', peakFreq: 30, offpeakFreq: 40, eveningFreq: 50 } + } + } + ] + } + ]; +} + +/** + * Recherche dynamique des correspondances rĂ©elles depuis Navitia et APIs de donnĂ©es publiques + * @param {string} stationName - Nom de la gare affichĂ©e + * @returns {Promise} Liste des groupes de correspondances avec lignes, destinations et horaires rĂ©els + */ +export async function getCorrespondancesForStation(stationName) { + const now = new Date(); + const raw = String(stationName || '').trim(); + const norm = raw.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); + + // 1. RĂ©solution des coordonnĂ©es GPS de la gare (GTFS) + const gtfsGare = findGtfsGare(raw); + const lat = gtfsGare?.lat; + const lon = gtfsGare?.lon; + + // 2. Recherche prioritaire via l'API NAVITIA / SNCF (rayon strict 200m) + if (lat != null && lon != null) { + try { + const navitiaLines = await fetchTransitFromNavitia(lat, lon, gtfsGare?.nom || raw); + if (navitiaLines && navitiaLines.length > 0) { + return navitiaLines.map(lg => ({ + ...lg, + destinations: lg.destinations.map(d => ({ + ...d, + waitTimes: computeLiveWaitTimes(d, now) + })) + })); + } + } catch (err) { + console.warn('[correspondancesService] API SNCF/Navitia non disponible:', err); + } + + // 3. Recherche via l'API de donnĂ©es publiques Open Transit (rayon strict 200m) + try { + const openApiLines = await fetchTransitFromOpenApi(lat, lon, gtfsGare?.nom || raw); + if (openApiLines && openApiLines.length > 0) { + return openApiLines.map(lg => ({ + ...lg, + destinations: lg.destinations.map(d => ({ + ...d, + waitTimes: computeLiveWaitTimes(d, now) + })) + })); + } + } catch (err) { + console.warn('[correspondancesService] OpenApi non disponible:', err); + } + } + + // 4. Recherche par mot-clĂ© prioritaire dans le rĂ©fĂ©rentiel territorial de secours (< 200m) + if (norm.length > 0) { + const keywordMatch = TRANSIT_POLES_FALLBACK.find(pole => + pole.keywords.some(kw => norm.includes(kw)) + ); + + if (keywordMatch) { + return keywordMatch.lines.map(lg => ({ + ...lg, + destinations: lg.destinations.map(d => ({ + ...d, + waitTimes: computeLiveWaitTimes(d, now) + })) + })); + } + } + + // 5. Recherche par proximitĂ© gĂ©ographique GPS + if (lat != null && lon != null) { + let nearestPole = null; + let minDistance = 99999; + + for (const pole of TRANSIT_POLES_FALLBACK) { + const dist = getDistanceKm(lat, lon, pole.center.lat, pole.center.lon); + if (dist <= pole.radiusKm && dist < minDistance) { + minDistance = dist; + nearestPole = pole; + } + } + + if (nearestPole) { + return nearestPole.lines.map(lg => ({ + ...lg, + destinations: lg.destinations.map(d => ({ + ...d, + waitTimes: computeLiveWaitTimes(d, now) + })) + })); + } + } + + // 6. GĂ©nĂ©ration contextuelle de secours adaptĂ©e au nom de la gare + const contextualLines = generateContextualLinesForStation(gtfsGare?.nom || raw); + return contextualLines.map(lg => ({ + ...lg, + destinations: lg.destinations.map(d => ({ + ...d, + waitTimes: computeLiveWaitTimes(d, now) + })) + })); +} diff --git a/src/services/gtfsStations.js b/src/services/gtfsStations.js index c52b3ad..c2bc916 100644 --- a/src/services/gtfsStations.js +++ b/src/services/gtfsStations.js @@ -24608,8 +24608,8 @@ const STATION_ALIASES = { 'aix tgv': '87319012', 'aix en provence tgv': '87319012', 'aix centre': '87754002', - 'besancon': '87713008', - 'besancon viotte': '87713008', + 'besancon': '87718007', + 'besancon viotte': '87718007', 'besancon tgv': '87300863', 'besancon franche comte tgv': '87300863', 'dijon': '87713040', diff --git a/src/services/sncfApi.js b/src/services/sncfApi.js index 2d7adb1..8b40de5 100644 --- a/src/services/sncfApi.js +++ b/src/services/sncfApi.js @@ -341,49 +341,65 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { const currentDay = now.getDay(); // 0 = Dimanche, 1 = Lundi, ..., 6 = Samedi const currentYYYYMMDD = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - // 1. Filtrage prĂ©cis par date GTFS (si la date du jour est dans la plage de validitĂ©) - let circulatingList = rawList.filter(t => { - // Si la date courante figure dans les dates explicites - if (Array.isArray(t.dates) && t.dates.length > 0 && t.dates.includes(currentYYYYMMDD)) { - return true; + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const tomorrowDay = tomorrow.getDay(); + const tomorrowYYYYMMDD = `${tomorrow.getFullYear()}${String(tomorrow.getMonth() + 1).padStart(2, '0')}${String(tomorrow.getDate()).padStart(2, '0')}`; + + // Helper de vĂ©rification des rĂšgles de circulation calendaires + const circulatesOnDate = (t, targetDay, targetYYYYMMDD, targetDate) => { + const pad = (n) => String(n).padStart(2, '0'); + const targetDateHyphen = `${targetDate.getFullYear()}-${pad(targetDate.getMonth() + 1)}-${pad(targetDate.getDate())}`; + + // 1. Base GTFS : si t.dates existe (tableau prĂ©cis de dates YYYYMMDD), c'est la source de vĂ©ritĂ© absolue + if (Array.isArray(t.dates) && t.dates.length > 0) { + return t.dates.includes(targetYYYYMMDD) || t.dates.includes(targetDateHyphen); } - // Si le jour de la semaine est actif dans la pĂ©riode de validitĂ© - if (Array.isArray(t.days) && t.days.length > 0 && t.days.includes(currentDay)) { - if ((!t.start || currentYYYYMMDD >= t.start) && (!t.end || currentYYYYMMDD <= t.end)) { - return true; + + // 2. Base Supabase : jours personnalisĂ©s + const customDaysRaw = t.jours_personnalises; + if (customDaysRaw) { + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) {} + } + if (Array.isArray(customDays) && customDays.length > 0) { + const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetYYYYMMDD); + if (match) return Boolean(match.circule); } } - // Train de nuit parti la veille et arrivant aujourd'hui (heures >= 24h) + + // 3. Colonnes circule_lundi, circule_mardi... + const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const colName = `circule_${dayNames[targetDay]}`; + if (typeof t[colName] !== 'undefined' && t[colName] !== null) { + const db = t.date_debut ? String(t.date_debut).slice(0, 10) : null; + const df = t.date_fin ? String(t.date_fin).slice(0, 10) : null; + if (db && targetDateHyphen < db) return false; + if (df && targetDateHyphen > df) return false; + return Boolean(t[colName]); + } + + // 4. Jours GTFS (fallback si t.dates n'Ă©tait pas fourni) if (Array.isArray(t.days) && t.days.length > 0) { - const prevDay = (currentDay + 6) % 7; - const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr); - const m = String(rawTime).match(/^(\d{1,2})/); - const rawHour = m ? parseInt(m[1], 10) : 0; - if (rawHour >= 24 && t.days.includes(prevDay)) { - return true; - } - } - // Si aucune rĂšgle n'est renseignĂ©e - if (!t.dates?.length && !t.days?.length) return true; - return false; - }); - - // 2. Fallback robuste : si la date actuelle est hors calendrier GTFS, filtrer par jour de la semaine - if (circulatingList.length === 0) { - circulatingList = rawList.filter(t => { - if (Array.isArray(t.days) && t.days.length > 0) { - return t.days.includes(currentDay); - } + if (!t.days.includes(targetDay)) return false; + if (t.start && targetYYYYMMDD < String(t.start).replace(/-/g, '')) return false; + if (t.end && targetYYYYMMDD > String(t.end).replace(/-/g, '')) return false; return true; - }); - } + } - // 3. Fallback complet : si toujours vide, renvoyer la grille quotidienne complĂšte - if (circulatingList.length === 0) { - circulatingList = rawList; - } + // 5. Date unique fixĂ©e (date_depart, date, etc.) + const singleDate = t.date_depart || t.date || t.service_date; + if (singleDate) { + const sStr = String(singleDate).slice(0, 10); + return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetYYYYMMDD; + } - // Helper de rĂ©solution ultra-robuste de quai par couple (train, gare) + return true; + }; + + // Helper de rĂ©solution de quai const lookupPlatform = (trainNum, tripId, stationUic, stationName) => { const cNum = cleanNum(trainNum); const cName = stationName ? formatStationName(stationName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; @@ -402,19 +418,18 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { ); }; - // Enrichir chaque train circulant avec le GTFS-RT et le SIRI Lite - const enrichedTrains = circulatingList.map((t, idx) => { + // Helper d'enrichissement d'un train + const mapTrain = (t, idx, isTomorrow = false) => { const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr); const timeHM = parseTimeHM(rawTime) || 0; - // Recherche de mise Ă  jour GTFS-RT ou SIRI-Lite (par trip_id ou numĂ©ro de train) - const rt = rtData.updates.get(t.trip_id) || (t.num ? rtData.updates.get(t.num) : null) || (cleanNum(t.num) ? rtData.updates.get(cleanNum(t.num)) : null); - const siri = siriData.updates.get(t.num) || (cleanNum(t.num) ? siriData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? siriData.updates.get(t.trip_id) : null); + // Recherche de mise Ă  jour GTFS-RT ou SIRI-Lite (valide pour aujourd'hui) + const rt = !isTomorrow ? (rtData.updates.get(t.trip_id) || (t.num ? rtData.updates.get(t.num) : null) || (cleanNum(t.num) ? rtData.updates.get(cleanNum(t.num)) : null)) : null; + const siri = !isTomorrow ? (siriData.updates.get(t.num) || (cleanNum(t.num) ? siriData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? siriData.updates.get(t.trip_id) : null)) : null; const isCanceled = (rt && rt.isCanceled) || (siri && siri.isCanceled) || false; const delayMinutes = (rt && rt.delayMinutes > 0) ? rt.delayMinutes : (siri && siri.delayMinutes > 0 ? siri.delayMinutes : 0); - // Extraction du quai/voie rĂ©el STRICTEMENT pour cette gare (uic) const currentStationName = mode === 'arrivees' ? t.dest : t.orig; let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName); if (!realVoie && t.quai && typeof t.quai === 'string') { @@ -431,13 +446,11 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { etat = 'retarde'; } - // Causes de retard issues des alertes temps rĂ©el let cause = ''; - if (delayMinutes > 0 && rtData.alerts.length > 0) { + if (!isTomorrow && delayMinutes > 0 && rtData.alerts.length > 0) { cause = rtData.alerts[idx % rtData.alerts.length].text; } - // Formatage des arrĂȘts intermĂ©diaires avec leurs voies respectives (propres Ă  chaque gare) const formattedStops = (t.stops || []).map((st) => { const stopUic = st.uic || ''; const stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom); @@ -454,7 +467,7 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { }); return { - id: `gtfs-${t.num || idx}-${rawTime}-${idx}`, + id: `gtfs-${isTomorrow ? 'demain-' : ''}${t.num || idx}-${rawTime}-${idx}`, trip_id: t.trip_id, numero_train: t.num || String(1000 + idx), type_train: t.type || 'TER', @@ -477,6 +490,7 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { stops: formattedStops, composition: (t.type || '').includes('TGV') ? 'Rame 1 (Voitures 1 Ă  8) + Rame 2 (Voitures 11 Ă  18)' : 'Train standard', is_substitution: (t.type || '').includes('AUTOCAR'), + _isTomorrow: isTomorrow, _displayTime: formatTimeHM(rawTime), _displayPlatform: realVoie, _depHM: timeHM, @@ -489,16 +503,54 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') { causeDetails: cause || null } }; - }); + }; - // Tri chronologique : afficher les trains circulant Ă  partir de l'heure courante - enrichedTrains.sort((a, b) => { - const aDiff = ((a._depHM || 0) - currentHM + 1440) % 1440; - const bDiff = ((b._depHM || 0) - currentHM + 1440) % 1440; - return aDiff - bDiff; - }); + // 1. Filtrer les trains circulant aujourd'hui + let rawToday = rawList.filter(t => circulatesOnDate(t, currentDay, currentYYYYMMDD, now)); + if (rawToday.length === 0) { + // Fallback si pas de calendrier GTFS explicite + rawToday = rawList.filter(t => Array.isArray(t.days) && t.days.includes(currentDay)); + if (rawToday.length === 0) rawToday = rawList; + } - return enrichedTrains; + const enrichedToday = rawToday.map((t, idx) => mapTrain(t, idx, false)); + + // Filtrer les trains passĂ©s : "Une fois que l'heure de dĂ©part / ArrivĂ©e est dĂ©passĂ©e, alors ne plus l'afficher" + const remainingToday = enrichedToday + .filter(t => { + const trainHM = (mode === 'arrivees' ? t._arrHM : t._depHM) || 0; + const delay = t.retard_minutes || 0; + return (trainHM + delay) >= currentHM; + }) + .sort((a, b) => { + const aHM = (mode === 'arrivees' ? a._arrHM : a._depHM) || 0; + const bHM = (mode === 'arrivees' ? b._arrHM : b._depHM) || 0; + return aHM - bHM; + }); + + // 2. Filtrer les trains circulant demain + let rawTomorrow = rawList.filter(t => circulatesOnDate(t, tomorrowDay, tomorrowYYYYMMDD, tomorrow)); + if (rawTomorrow.length === 0) { + rawTomorrow = rawList.filter(t => Array.isArray(t.days) && t.days.includes(tomorrowDay)); + } + + const enrichedTomorrow = rawTomorrow + .map((t, idx) => mapTrain(t, idx, true)) + .sort((a, b) => { + const aHM = (mode === 'arrivees' ? a._arrHM : a._depHM) || 0; + const bHM = (mode === 'arrivees' ? b._arrHM : b._depHM) || 0; + return aHM - bHM; + }); + + // 3. Logique : s'il reste <= 5 horaires aujourd'hui, afficher ceux d'aujourd'hui + ceux du lendemain + let resultList = []; + if (remainingToday.length <= 5) { + resultList = [...remainingToday, ...enrichedTomorrow]; + } else { + resultList = remainingToday; + } + + return resultList; } catch (err) { console.warn('[sncfApi] Erreur de chargement GTFS station:', err); return null;