RELEASE V5.5.0 : Voici les modifications apportées :

-> Implémentation des afficheurs "Sécurité"; "Informations" et "Correspondances" pour les afficheurs AFL

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

-> REFONTE DU DESIGN DES AFFICHEURS CLASSIQUES
This commit is contained in:
mathis committed 2026-08-30 21:16:38 +02:00
1 parent 1c83953552
commit 4f3a34b101
24 files changed
+3509 -404

No files matched your search

+69
View File
@@ -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.
+108
View File
@@ -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";
# }
# }
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+201 -7
View File
@@ -16,6 +16,29 @@ export default function SelectAfficheur({ onBack, navigate }) {
const [manualPlatform, setManualPlatform] = useState(''); const [manualPlatform, setManualPlatform] = useState('');
const [loading, setLoading] = useState(true); 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 // Decode and format station name for display
const decodedGare = gareParam const decodedGare = gareParam
? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ')
@@ -49,7 +72,19 @@ export default function SelectAfficheur({ onBack, navigate }) {
const handleGeneral = () => { const handleGeneral = () => {
if (typeof navigate === 'function') { if (typeof navigate === 'function') {
const kebab = gareParam ? String(gareParam).trim() : ''; 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 ( return (
<div style={{ <div style={{
maxWidth: '650px', maxWidth: '780px',
margin: '40px auto', margin: '40px auto',
padding: '32px', padding: '32px',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -102,7 +144,7 @@ export default function SelectAfficheur({ onBack, navigate }) {
fontSize: '16px', fontSize: '16px',
lineHeight: 1.5 lineHeight: 1.5
}}> }}>
Pour la gare de <strong>{decodedGare}</strong> en mode <strong>{modeParam === 'arrivees' ? 'Arrivées' : 'Départs'}</strong>. Pour la gare de <strong>{decodedGare}</strong> en mode <strong>{modeParam === 'arrivees' ? 'Arrivées' : 'Départs'}</strong> (Style : <strong>{typeParam.toUpperCase()}</strong>).
</p> </p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '32px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '32px' }}>
@@ -117,11 +159,112 @@ export default function SelectAfficheur({ onBack, navigate }) {
alignItems: 'flex-start', alignItems: 'flex-start',
background: '#fafbfc' background: '#fafbfc'
}}> }}>
<div> <div style={{ width: '100%' }}>
<h3 style={{ margin: '0 0 12px 0', color: '#00439e', fontSize: '20px' }}>Afficheur Général</h3> <h3 style={{ margin: '0 0 12px 0', color: '#00439e', fontSize: '20px' }}>Afficheur Général</h3>
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px', lineHeight: 1.4 }}> <p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px', lineHeight: 1.4 }}>
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.
</p> </p>
{typeParam === 'afl' && (
<div style={{
background: '#ffffff',
border: '1px solid #d0e1fd',
borderRadius: '8px',
padding: '14px',
marginBottom: '20px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '13px', fontWeight: 700, color: '#002257' }}>
Pages à inclure dans la rotation :
</span>
<div style={{ display: 'flex', gap: '6px' }}>
<button
type="button"
onClick={() => selectAllAflPages(true)}
style={{
background: 'none',
border: 'none',
color: '#00439e',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
textDecoration: 'underline'
}}
>
Tout cocher
</button>
<span style={{ color: '#ccc', fontSize: '11px' }}>|</span>
<button
type="button"
onClick={() => selectAllAflPages(false)}
style={{
background: 'none',
border: 'none',
color: '#666',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
textDecoration: 'underline'
}}
>
Horaires seuls
</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.horaires}
onChange={() => toggleAflPage('horaires')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Page 1 & 2</strong> (Horaires des trains)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.infos}
onChange={() => toggleAflPage('infos')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Informations</strong> (Trafic & Travaux)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.securite1}
onChange={() => toggleAflPage('securite1')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Sécurité 1</strong> (Bagages / Objets délaissés)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.securite2}
onChange={() => toggleAflPage('securite2')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Sécurité 2</strong> (Vigipirate / Alerte 3117)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#222', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aflPages.corresp}
onChange={() => toggleAflPage('corresp')}
style={{ width: '16px', height: '16px', accentColor: '#00439e' }}
/>
<span><strong>Correspondances</strong> (Transports urbains & bus)</span>
</label>
</div>
</div>
)}
</div> </div>
<button <button
onClick={handleGeneral} onClick={handleGeneral}
@@ -171,7 +314,7 @@ export default function SelectAfficheur({ onBack, navigate }) {
value={selectedPlatform} value={selectedPlatform}
onChange={(e) => { onChange={(e) => {
setSelectedPlatform(e.target.value); setSelectedPlatform(e.target.value);
setManualPlatform(''); // Reset manual entry on selection setManualPlatform('');
}} }}
style={{ style={{
width: '100%', width: '100%',
@@ -234,6 +377,56 @@ export default function SelectAfficheur({ onBack, navigate }) {
</div> </div>
</div> </div>
{typeParam === 'afl' && (
<div style={{
borderTop: '1px solid #eef2f8',
paddingTop: '20px',
marginBottom: '20px'
}}>
<h4 style={{ margin: '0 0 10px 0', fontSize: '14px', color: '#555', fontWeight: 600 }}>
Accès direct à une page AFL spécifique (fixe) :
</h4>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => handleSinglePage('1')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Horaires (Page 1)
</button>
<button
onClick={() => handleSinglePage('2')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Horaires (Page 2)
</button>
<button
onClick={() => handleSinglePage('infos')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Informations
</button>
<button
onClick={() => handleSinglePage('securite1')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Sécurité 1
</button>
<button
onClick={() => handleSinglePage('securite2')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Sécurité 2
</button>
<button
onClick={() => handleSinglePage('corresp')}
style={{ padding: '6px 12px', fontSize: '12px', borderRadius: '4px', border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}
>
Correspondances
</button>
</div>
</div>
)}
<div style={{ <div style={{
borderTop: '1px solid #eeeeee', borderTop: '1px solid #eeeeee',
paddingTop: '20px', paddingTop: '20px',
@@ -255,3 +448,4 @@ export default function SelectAfficheur({ onBack, navigate }) {
</div> </div>
); );
} }
+172 -69
View File
@@ -7,6 +7,9 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce';
import { getSourceFromPath } from '../../../services/railwayService'; import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi'; 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) // Helper utilitaire : format HHhMM sans secondes (ex: 06h44)
const formatTimeNoSeconds = (v) => { const formatTimeNoSeconds = (v) => {
@@ -132,11 +135,35 @@ export default function AFLArrivees(){
// gérer les +, décoder et remplacer les tirets par des espaces // gérer les +, décoder et remplacer les tirets par des espaces
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; 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 // Support pour verrouiller l'affichage sur une page spécifique via l'URL (?page=...)
const pageParam = params.get('page'); // ?page=1 -> Horaires Page 1
const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10)) // ?page=2 -> Horaires Page 2
? Math.max(0, parseInt(pageParam, 10) - 1) // ?page=3 | ?page=info | ?page=informations -> Informations
: null; // ?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 // debug flag activable via ?debug=1 or ?debug=true
const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === '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([]); const [perturbations, setPerturbations] = useState([]);
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const [stationType, setStationType] = useState(null); const [stationType, setStationType] = useState(null);
const [, setTravaux] = useState([]); const [travaux, setTravaux] = useState([]);
const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); const [travauxSubstitutions, setTravauxSubstitutions] = useState([]);
const [, setLoading] = useState(false); const [, setLoading] = useState(false);
const [, setError] = useState(null); const [, setError] = useState(null);
@@ -685,7 +712,7 @@ export default function AFLArrivees(){
} }
}; };
}); });
setHoraires(formatted.slice(0, 10)); setHoraires(formatted);
setAllHoraires(formatted); setAllHoraires(formatted);
} }
} catch (e) { } catch (e) {
@@ -861,30 +888,54 @@ export default function AFLArrivees(){
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
if (!h || !d) return false;
const pad = (n) => String(n).padStart(2, '0'); 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; const customDaysRaw = h.jours_personnalises;
if (!customDaysRaw) return false; if (customDaysRaw) {
let customDays = [];
let customDays = []; if (Array.isArray(customDaysRaw)) {
if (Array.isArray(customDaysRaw)) { customDays = customDaysRaw;
customDays = customDaysRaw; } else if (typeof customDaysRaw === 'string') {
} else if (typeof customDaysRaw === 'string') { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
try { }
customDays = JSON.parse(customDaysRaw); if (Array.isArray(customDays) && customDays.length > 0) {
} catch (e) { const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact);
return false; if (match) return Boolean(match.circule);
} }
} }
if (Array.isArray(customDays)) { const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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_* // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_*
@@ -1011,6 +1062,10 @@ export default function AFLArrivees(){
let serviceDateStr; let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) { if (todayCandidates && todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); 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; serviceDateStr = todayStr;
} else { } else {
finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); 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) // 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); const [alternateDelay, setAlternateDelay] = useState(false);
useEffect(() => { useEffect(() => {
@@ -1096,32 +1154,95 @@ export default function AFLArrivees(){
return () => clearInterval(delayTimer); return () => clearInterval(delayTimer);
}, []); }, []);
const getPageBounds = useCallback((pIndex) => { // Construction des étapes de rotation :
if (pIndex === 0) return { start: 0, end: 5 }; // 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 start = 5 + (pIndex - 1) * 8; const pagesParam = params.get('pages');
return { start, end: start + 8 }; const selectedPagesList = pagesParam ? pagesParam.split(',').map(s => s.trim().toLowerCase()) : null;
}, []); const hasFlag = (key) => params.get(key) === '1' || params.get(key) === 'true';
const totalPages = Math.max(1, horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8)); 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(() => { useEffect(() => {
if (lockedPageIndex !== null) { if (lockedStep !== null) return;
setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); if (cycleSteps.length <= 1) return;
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]);
const { start, end } = getPageBounds(pageIndex); const pageInterval = setInterval(() => {
const displayedHoraires = horaires.slice(start, end); setCurrentStepIndex((prev) => (prev + 1) % cycleSteps.length);
const isPage1 = pageIndex === 0; }, 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 (
<AFLInformationsPage
mode="arrivees"
gareName={effectiveGareName}
perturbations={perturbations}
travaux={travaux}
/>
);
}
if (activeStep.type === 'SECURITE') {
return (
<AFLSecurityPage
variant={activeStep.variant || securityVariant}
/>
);
}
if (activeStep.type === 'CORRESPONDANCES') {
return (
<AFLCorrespondancesPage
gareName={effectiveGareName}
/>
);
}
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 ( return (
<div className="afl-arr-container"> <div className="afl-arr-container">
@@ -1178,26 +1299,8 @@ export default function AFLArrivees(){
'' ''
).toString().trim(); ).toString().trim();
const isCG = rawPlatform.toUpperCase() === '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));
rawPlatform.toUpperCase().includes('CG') || const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR');
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));
let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || '');
const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue'; 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 status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));
const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé'); 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 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'; const pillClass = isSuppressed ? 'afl-arr-pill--suppressed' : isDelayed ? 'afl-arr-pill--delayed' : 'afl-arr-pill--ontime';
+50 -22
View File
@@ -460,31 +460,55 @@ const AFLArriveesQuais = () => {
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
const pad = (n) => String(n).padStart(2, '0'); if (!h || !d) return false;
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; 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 (Array.isArray(h.dates) && h.dates.length > 0) {
if (!customDaysRaw) return false; return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen);
}
let customDays = []; const customDaysRaw = h.jours_personnalises;
if (Array.isArray(customDaysRaw)) { if (customDaysRaw) {
customDays = customDaysRaw; let customDays = [];
} else if (typeof customDaysRaw === 'string') { if (Array.isArray(customDaysRaw)) {
try { customDays = customDaysRaw;
customDays = JSON.parse(customDaysRaw); } else if (typeof customDaysRaw === 'string') {
} catch (e) { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
return false; }
} 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 dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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;
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) => { const parseDateOnly = (v) => {
if (!v) return null; if (!v) return null;
@@ -608,6 +632,10 @@ const AFLArriveesQuais = () => {
let serviceDateStr = ''; let serviceDateStr = '';
if (todayCandidates.length > 0) { if (todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a, b) => a._depHM - b._depHM); 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; serviceDateStr = todayStr;
} else { } else {
finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM); finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM);
+172 -69
View File
@@ -7,6 +7,9 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce';
import { getSourceFromPath } from '../../../services/railwayService'; import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi'; 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) // Helper utilitaire : format HHhMM sans secondes (ex: 06h44)
const formatTimeNoSeconds = (v) => { const formatTimeNoSeconds = (v) => {
@@ -135,11 +138,35 @@ export default function AFLDeparts(){
// gérer les +, décoder et remplacer les tirets par des espaces // gérer les +, décoder et remplacer les tirets par des espaces
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; 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 // Support pour verrouiller l'affichage sur une page spécifique via l'URL (?page=...)
const pageParam = params.get('page'); // ?page=1 -> Horaires Page 1
const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10)) // ?page=2 -> Horaires Page 2
? Math.max(0, parseInt(pageParam, 10) - 1) // ?page=3 | ?page=info | ?page=informations -> Informations
: null; // ?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 // debug flag activable via ?debug=1 or ?debug=true
const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === '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', ...) // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...)
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const [stationType, setStationType] = useState(null); const [stationType, setStationType] = useState(null);
const [, setTravaux] = useState([]); const [travaux, setTravaux] = useState([]);
const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); const [travauxSubstitutions, setTravauxSubstitutions] = useState([]);
const [, setLoading] = useState(false); const [, setLoading] = useState(false);
const [, setError] = useState(null); const [, setError] = useState(null);
@@ -695,7 +722,7 @@ export default function AFLDeparts(){
causeDetails: t.cause_retard || null causeDetails: t.cause_retard || null
} }
})); }));
setHoraires(formatted.slice(0, 10)); setHoraires(formatted);
setAllHoraires(formatted); setAllHoraires(formatted);
} }
} catch (e) { } catch (e) {
@@ -886,30 +913,54 @@ export default function AFLDeparts(){
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
if (!h || !d) return false;
const pad = (n) => String(n).padStart(2, '0'); 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; const customDaysRaw = h.jours_personnalises;
if (!customDaysRaw) return false; if (customDaysRaw) {
let customDays = [];
let customDays = []; if (Array.isArray(customDaysRaw)) {
if (Array.isArray(customDaysRaw)) { customDays = customDaysRaw;
customDays = customDaysRaw; } else if (typeof customDaysRaw === 'string') {
} else if (typeof customDaysRaw === 'string') { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
try { }
customDays = JSON.parse(customDaysRaw); if (Array.isArray(customDays) && customDays.length > 0) {
} catch (e) { const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact);
return false; if (match) return Boolean(match.circule);
} }
} }
if (Array.isArray(customDays)) { const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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_* // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_*
@@ -1036,6 +1087,10 @@ export default function AFLDeparts(){
let serviceDateStr; let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) { if (todayCandidates && todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); 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; serviceDateStr = todayStr;
} else { } else {
finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); 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) // 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); const [alternateDelay, setAlternateDelay] = useState(false);
useEffect(() => { useEffect(() => {
@@ -1121,32 +1179,95 @@ export default function AFLDeparts(){
return () => clearInterval(delayTimer); return () => clearInterval(delayTimer);
}, []); }, []);
const getPageBounds = useCallback((pIndex) => { // Construction des étapes de rotation :
if (pIndex === 0) return { start: 0, end: 5 }; // 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 start = 5 + (pIndex - 1) * 8; const pagesParam = params.get('pages');
return { start, end: start + 8 }; const selectedPagesList = pagesParam ? pagesParam.split(',').map(s => s.trim().toLowerCase()) : null;
}, []); const hasFlag = (key) => params.get(key) === '1' || params.get(key) === 'true';
const totalPages = Math.max(1, horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8)); 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(() => { useEffect(() => {
if (lockedPageIndex !== null) { if (lockedStep !== null) return;
setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); if (cycleSteps.length <= 1) return;
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]);
const { start, end } = getPageBounds(pageIndex); const pageInterval = setInterval(() => {
const displayedHoraires = horaires.slice(start, end); setCurrentStepIndex((prev) => (prev + 1) % cycleSteps.length);
const isPage1 = pageIndex === 0; }, 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 (
<AFLInformationsPage
mode="departs"
gareName={effectiveGareName}
perturbations={perturbations}
travaux={travaux}
/>
);
}
if (activeStep.type === 'SECURITE') {
return (
<AFLSecurityPage
variant={activeStep.variant || securityVariant}
/>
);
}
if (activeStep.type === 'CORRESPONDANCES') {
return (
<AFLCorrespondancesPage
gareName={effectiveGareName}
/>
);
}
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 ( return (
<div className="afl-container"> <div className="afl-container">
@@ -1203,26 +1324,8 @@ export default function AFLDeparts(){
'' ''
).toString().trim(); ).toString().trim();
const isCG = rawPlatform.toUpperCase() === '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));
rawPlatform.toUpperCase().includes('CG') || const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR');
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));
let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || '');
const destination = horaire.gare_arrivee_nom || ''; 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 status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));
const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé'); 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 isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard');
const pillClass = isSuppressed ? 'afl-pill--suppressed' : isDelayed ? 'afl-pill--delayed' : 'afl-pill--ontime'; const pillClass = isSuppressed ? 'afl-pill--suppressed' : isDelayed ? 'afl-pill--delayed' : 'afl-pill--ontime';
+50 -22
View File
@@ -530,31 +530,55 @@ const AFLDepartsQuais = () => {
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
const pad = (n) => String(n).padStart(2, '0'); if (!h || !d) return false;
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; 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 (Array.isArray(h.dates) && h.dates.length > 0) {
if (!customDaysRaw) return false; return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen);
}
let customDays = []; const customDaysRaw = h.jours_personnalises;
if (Array.isArray(customDaysRaw)) { if (customDaysRaw) {
customDays = customDaysRaw; let customDays = [];
} else if (typeof customDaysRaw === 'string') { if (Array.isArray(customDaysRaw)) {
try { customDays = customDaysRaw;
customDays = JSON.parse(customDaysRaw); } else if (typeof customDaysRaw === 'string') {
} catch (e) { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
return false; }
} 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 dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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;
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) => { const parseDateOnly = (v) => {
if (!v) return null; if (!v) return null;
@@ -682,6 +706,10 @@ const AFLDepartsQuais = () => {
let serviceDateStr = ''; let serviceDateStr = '';
if (todayCandidates.length > 0) { if (todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a, b) => a._depHM - b._depHM); 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; serviceDateStr = todayStr;
} else { } else {
finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM); finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM);
@@ -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;
}
@@ -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 (
<div className="afl-corresp-clock" aria-hidden>
<span>{hh}:{mm}</span>
</div>
);
}
// Icône de Pôle d'échange / Gare Correspondances (bâtiment + trajet en pointillés)
const StationTransferIcon = ({ size = '52px', color = '#f5c518' }) => (
<svg
viewBox="0 0 48 48"
width={size}
height={size}
fill="none"
style={{ flexShrink: 0 }}
>
{/* Bâtiment gare / Pôle d'échange */}
<path
d="M6 22 L24 8 L42 22 V40 H6 Z"
stroke={color}
strokeWidth="3.5"
strokeLinejoin="round"
/>
<rect x="19" y="24" width="10" height="16" stroke={color} strokeWidth="3" />
<path d="M12 24 H15 M33 24 H36" stroke={color} strokeWidth="3.5" strokeLinecap="round" />
<path d="M12 32 H15 M33 32 H36" stroke={color} strokeWidth="3.5" strokeLinecap="round" />
{/* Trajet pointillé de correspondance */}
<path
d="M6 44 H42"
stroke={color}
strokeWidth="3"
strokeDasharray="4 4"
strokeLinecap="round"
/>
<circle cx="42" cy="44" r="3.5" fill={color} />
</svg>
);
// Icône de Transport Multimodal (Bus, Car, Tram)
const TransitCorrespIcon = ({ type = 'bus', size = '56px', color = '#ffffff' }) => (
<div className="afl-corresp-icon-box">
{type === 'tram' ? (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2l2 4h-4l2-4z" />
<rect x="4" y="6" width="16" height="13" rx="3" />
<line x1="4" y1="12" x2="20" y2="12" />
<circle cx="8" cy="16" r="1.5" fill={color} />
<circle cx="16" cy="16" r="1.5" fill={color} />
<line x1="8" y1="19" x2="8" y2="21" strokeWidth="2.4" />
<line x1="16" y1="19" x2="16" y2="21" strokeWidth="2.4" />
</svg>
) : type === 'car' ? (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="18" height="13" rx="3" />
<line x1="3" y1="11" x2="21" y2="11" />
<circle cx="7" cy="15" r="1.5" fill={color} />
<circle cx="17" cy="15" r="1.5" fill={color} />
<line x1="6" y1="18" x2="6" y2="20" strokeWidth="2.4" />
<line x1="18" y1="18" x2="18" y2="20" strokeWidth="2.4" />
<line x1="7" y1="8" x2="17" y2="8" strokeWidth="1.5" />
</svg>
) : (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="3" width="16" height="16" rx="3" />
<path d="M4 11h16" />
<circle cx="8" cy="15" r="1.5" fill={color} />
<circle cx="16" cy="15" r="1.5" fill={color} />
<path d="M6 19v2M18 19v2" strokeWidth="2.4" />
<path d="M9 6h6" strokeWidth="2" />
</svg>
)}
</div>
);
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 (
<div className="afl-corresp-page">
<header className="afl-corresp-header">
<div className="afl-corresp-header-left">
<StationTransferIcon />
<div className="afl-corresp-header-title">Correspondances</div>
</div>
<div className="afl-corresp-header-right">
<CorrespondancesClock now={currentTime} />
</div>
</header>
<div className="afl-corresp-empty">
Aucune correspondance directe actuellement signalée dans cette gare.
</div>
</div>
);
}
return (
<div className="afl-corresp-page">
{/* EN-TÊTE GRIS ARDOISE */}
<header className="afl-corresp-header">
<div className="afl-corresp-header-left">
<StationTransferIcon />
<div className="afl-corresp-header-title">Correspondances</div>
</div>
<div className="afl-corresp-header-right">
<CorrespondancesClock now={currentTime} />
</div>
</header>
{/* LISTE DES CORRESPONDANCES (Strictement conforme à l'Image 1) */}
<div className="afl-corresp-list">
{correspondances.map((cGroup) => (
<div key={cGroup.id} className="afl-corresp-group">
{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 (
<div key={dIdx} className="afl-corresp-row">
{/* COLONNE GAUCHE : Picto Transport et Badge Ligne UNIQUEMENT sur la 1ère destination du groupe */}
<div className="afl-corresp-col-line">
{isFirstInGroup ? (
<>
<TransitCorrespIcon type={cGroup.type} size="56px" />
<div
className="afl-corresp-line-badge"
style={{
backgroundColor: cGroup.color || '#4a4e57',
color: cGroup.textColor || '#ffffff'
}}
>
{cGroup.line}
</div>
</>
) : (
<div className="afl-corresp-line-spacer" />
)}
</div>
{/* COLONNE CENTRE : Arrêt - Destination */}
<div className="afl-corresp-col-dest">
<span className="afl-corresp-dest-text">{dest.name}</span>
</div>
{/* COLONNE DROITE : 2 Boîtes Minutes d'attente compatibles XXhXX min */}
<div className="afl-corresp-col-times">
{times.map((wait, wIdx) => {
const formatted = formatWaitTimeDisplay(wait);
return (
<div key={wIdx} className="afl-corresp-time-box">
<span className="afl-corresp-time-val">{formatted.val}</span>
{formatted.unit && <span className="afl-corresp-time-unit">{formatted.unit}</span>}
</div>
);
})}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
);
}
@@ -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;
}
@@ -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 (
<div className="afl-info-clock" aria-hidden>
<span>{hh}:{mm}</span>
</div>
);
}
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 (
<div className={`afl-info-page ${themeClass}`}>
{/* EN-TÊTE AUX COULEURS AFL */}
<header className="afl-info-header">
<div className="afl-info-header-left">
{/* Icône Information Blanche */}
<svg
viewBox="0 0 24 24"
width="52px"
height="52px"
fill="none"
stroke="#ffffff"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0 }}
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" strokeWidth="3.2" />
</svg>
<div className="afl-info-header-title">Informations</div>
</div>
<div className="afl-info-header-right">
<InfoClock />
</div>
</header>
{/* CONTENU PRINCIPAL DE LA PAGE INFORMATIONS */}
<div className="afl-info-content">
{hasPerturbations || hasTravaux ? (
<div className="afl-info-cards-container">
{/* CARTE PERTURBATION */}
{hasPerturbations && (
<div className="afl-info-card afl-info-card--perturbation">
<div className="afl-info-card-header">
<div className="afl-info-badge afl-info-badge--alert">
<svg viewBox="0 0 24 24" width="28px" height="28px" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" strokeWidth="3" />
</svg>
<span>Information Trafic</span>
</div>
<div className="afl-info-station-name">{gareName || 'Votre Gare'}</div>
</div>
<div className="afl-info-card-body">
{activePerturbs.slice(0, 2).map((p, idx) => (
<div key={idx} className="afl-info-perturb-item">
<div className="afl-info-perturb-title">
{p.ligne_nom || p.ligne || p.type_perturbation || 'Perturbation en cours'}
</div>
<div className="afl-info-perturb-desc">
{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.'}
</div>
</div>
))}
</div>
</div>
)}
{/* CARTE TRAVAUX */}
{hasTravaux && (
<div className="afl-info-card afl-info-card--travaux">
<div className="afl-info-card-header">
<div className="afl-info-badge afl-info-badge--travaux">
<svg viewBox="0 0 24 24" width="28px" height="28px" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span>Travaux de maintenance</span>
</div>
</div>
<div className="afl-info-card-body">
{activeTravaux.map((t, idx) => (
<div key={idx} className="afl-info-travaux-item">
<div className="afl-info-travaux-title">{t.nom || t.intitule || 'Modernisation du réseau'}</div>
<div className="afl-info-travaux-desc">
{t.description || t.motif || 'Des substitutions par autocar peuvent être mises en place pendant la période de travaux.'}
</div>
</div>
))}
</div>
</div>
)}
</div>
) : (
/* ÉCRAN NOMINAL : INFORMATIONS VOYAGEURS & TRAFIC FLUIDE */
<div className="afl-info-nominal-container">
{/* CARTE 1 : STATUT TRAFIC NORMAL */}
<div className="afl-info-nominal-card">
<div className="afl-info-nominal-icon-wrap afl-info-nominal-icon--green">
<svg viewBox="0 0 24 24" width="68px" height="68px" fill="none" stroke="#ffffff" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
</div>
<div className="afl-info-nominal-text">
<div className="afl-info-nominal-title">Trafic normal</div>
<div className="afl-info-nominal-subtitle">
La circulation des trains est fluide sur l'ensemble de votre ligne.
</div>
</div>
</div>
{/* CARTE 2 : CONSEILS VOYAGEURS ET APPLICATION */}
<div className="afl-info-nominal-card">
<div className="afl-info-nominal-icon-wrap afl-info-nominal-icon--blue">
<svg viewBox="0 0 24 24" width="68px" height="68px" fill="none" stroke="#ffffff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="3" />
<line x1="12" y1="18" x2="12.01" y2="18" strokeWidth="3" />
</svg>
</div>
<div className="afl-info-nominal-text">
<div className="afl-info-nominal-title">SNCF Connect &amp; Services</div>
<div className="afl-info-nominal-subtitle">
Retrouvez vos horaires en direct, achetez vos billets et suivez votre train en temps réel sur l'application SNCF Connect.
</div>
</div>
</div>
{/* CARTE 3 : CONSEILS DE SÉCURITÉ EN GARE */}
<div className="afl-info-nominal-card">
<div className="afl-info-nominal-icon-wrap afl-info-nominal-icon--yellow">
<svg viewBox="0 0 24 24" width="68px" height="68px" fill="none" stroke="#ffffff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12V8H6a2 2 0 0 1-2-2c0-1.1.9-2 2-2h12v4" />
<path d="M4 6v12c0 1.1.9 2 2 2h14v-4" />
<path d="M18 12a2 2 0 0 0-2 2c0 1.1.9 2 2 2h4v-4h-4z" />
</svg>
</div>
<div className="afl-info-nominal-text">
<div className="afl-info-nominal-title">Étiquetage de vos bagages</div>
<div className="afl-info-nominal-subtitle">
Pensez à apposer une étiquette visible avec vos nom, prénom et numéro de téléphone sur tous vos bagages.
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -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;
}
@@ -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 (
<div className="afl-sec-clock" aria-hidden>
<span>{hh}:{mm}</span>
</div>
);
}
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 (
<div className={`afl-sec-page afl-sec-page--variant-${variant}`}>
{/* EN-TÊTE BLANCHE COMMUNE */}
<header className="afl-sec-header">
<div className="afl-sec-header-left">
{/* Icône Triangle Danger Noir */}
<svg
viewBox="0 0 24 24"
width="52px"
height="52px"
fill="none"
stroke="#111111"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0 }}
>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" strokeWidth="3.2" />
</svg>
<div className="afl-sec-header-title">Sécurité & Sûreté</div>
</div>
<div className="afl-sec-header-right">
<SecurityClock />
</div>
</header>
{/* CONTENU VARIANT 1 : Traversée des voies / Trains dans les deux sens (Image 2) */}
{variant === 1 && (
<div className="afl-sec-content afl-sec-content--orange">
{/* Ruban rayé sécurité orange / blanc */}
<div className="afl-sec-hazard-ribbon" />
<div className="afl-sec-orange-body">
{/* LIGNE 1 : Trains circulent dans les deux sens */}
<div className="afl-sec-orange-row">
<div className="afl-sec-orange-pictos">
{/* Pictogramme Danger Triangle Blanc */}
<div className="afl-sec-picto-wrap">
<svg viewBox="0 0 100 100" width="125px" height="125px">
<path
d="M50 12 L92 84 L8 84 Z"
fill="none"
stroke="#ffffff"
strokeWidth="8"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path
d="M50 36 L50 60"
stroke="#ffffff"
strokeWidth="8"
strokeLinecap="round"
/>
<circle cx="50" cy="73" r="5" fill="#ffffff" />
</svg>
</div>
{/* Pictogramme Train et personne traversant */}
<div className="afl-sec-picto-wrap">
<svg viewBox="0 0 160 120" width="180px" height="125px" fill="none">
{/* Quai / Ligne de sol */}
<line x1="10" y1="88" x2="150" y2="88" stroke="#ffffff" strokeWidth="5" strokeLinecap="round" />
<line x1="20" y1="88" x2="15" y2="105" stroke="#ffffff" strokeWidth="4" />
<line x1="70" y1="88" x2="65" y2="105" stroke="#ffffff" strokeWidth="4" />
<line x1="120" y1="88" x2="115" y2="105" stroke="#ffffff" strokeWidth="4" />
{/* Silhouette de personne sautant/courant */}
<circle cx="55" cy="30" r="7" fill="#ffffff" />
<path d="M48 42 L62 42 L72 58 L62 60 L56 50 L52 70 L42 78 L45 84" stroke="#ffffff" strokeWidth="4.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M48 42 L40 54 L32 50" stroke="#ffffff" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
{/* Face avant du train */}
<rect x="95" y="28" width="45" height="52" rx="12" fill="#ffffff" />
<rect x="101" y="35" width="33" height="18" rx="5" fill="#d63e18" />
{/* Feux */}
<circle cx="106" cy="67" r="3.5" fill="#d63e18" />
<circle cx="129" cy="67" r="3.5" fill="#d63e18" />
{/* Ondes / Rayons de vitesse */}
<path d="M145 35 L153 30 M146 45 L155 45 M145 55 L153 60" stroke="#ffffff" strokeWidth="3.5" strokeLinecap="round" />
</svg>
</div>
</div>
<div className="afl-sec-orange-text">
Dans votre gare, des trains circulent dans les deux sens.
</div>
</div>
{/* LIGNE 2 : Signalisation & regarder de chaque côté */}
<div className="afl-sec-orange-row">
<div className="afl-sec-orange-pictos">
{/* Pictogramme Interdiction piéton avec feu clignotant */}
<div className="afl-sec-picto-wrap">
<svg viewBox="0 0 100 100" width="125px" height="125px" fill="none">
{/* Cercle barré */}
<circle cx="45" cy="50" r="36" stroke="#ffffff" strokeWidth="6" />
<line x1="22" y1="27" x2="68" y2="73" stroke="#ffffff" strokeWidth="6" />
{/* Silhouette piéton */}
<circle cx="45" cy="35" r="4.5" fill="#ffffff" />
<path d="M45 42 L45 58 M45 48 L37 56 M45 48 L53 54 M45 58 L39 70 M45 58 L51 70" stroke="#ffffff" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
{/* Feu avec rayons */}
<circle cx="82" cy="28" r="5" fill="#ffffff" />
<path d="M82 14 L82 8 M72 20 L66 16 M92 20 L98 16 M72 36 L66 40 M92 36 L98 40" stroke="#ffffff" strokeWidth="3" strokeLinecap="round" />
<line x1="82" y1="35" x2="82" y2="85" stroke="#ffffff" strokeWidth="3.5" />
</svg>
</div>
{/* Pictogramme Piéton vert sur platelage avec signalisation */}
<div className="afl-sec-picto-wrap">
<svg viewBox="0 0 120 100" width="145px" height="125px" fill="none">
{/* Platelage / voie de traversée */}
<line x1="10" y1="84" x2="110" y2="84" stroke="#ffffff" strokeWidth="5" strokeLinecap="round" />
<line x1="20" y1="84" x2="15" y2="98" stroke="#ffffff" strokeWidth="3.5" />
<line x1="60" y1="84" x2="55" y2="98" stroke="#ffffff" strokeWidth="3.5" />
<line x1="100" y1="84" x2="95" y2="98" stroke="#ffffff" strokeWidth="3.5" />
{/* Silhouette Piéton Vert */}
<circle cx="55" cy="28" r="6" fill="#3cd070" />
<path d="M55 36 L55 54 L44 68 M55 42 L65 52 M55 42 L45 48 M55 54 L65 72" stroke="#3cd070" strokeWidth="4.5" strokeLinecap="round" strokeLinejoin="round" />
{/* Mât de signalisation */}
<line x1="90" y1="20" x2="90" y2="84" stroke="#ffffff" strokeWidth="4" strokeLinecap="round" />
<rect x="85" y="16" width="10" height="22" rx="3" fill="#ffffff" />
</svg>
</div>
</div>
<div className="afl-sec-orange-text">
Avant de traverser, respectez la signalisation et regardez de chaque côté.
</div>
</div>
{/* Logo SNCF GARES & CONNEXIONS en bas de page */}
<div className="afl-sec-orange-footer">
<div className="afl-sec-sncf-badge">
<span className="afl-sec-sncf-bold">SNCF</span>
<span className="afl-sec-sncf-sub">GARES &amp; CONNEXIONS</span>
</div>
</div>
</div>
</div>
)}
{/* CONTENU VARIANT 2 : ATTENTIFS ENSEMBLE / Alerte 3117 (Image 3) */}
{variant === 2 && (
<div className="afl-sec-content afl-sec-content--anthracite">
<div className="afl-sec-vigipirate-grid">
{/* COLONNE GAUCHE : Titre et Logo Vigipirate */}
<div className="afl-sec-left-col">
<div className="afl-sec-vigipirate-title">
ATTENTIFS<br />ENSEMBLE
</div>
{/* Logo Officiel Vigipirate fourni */}
<div className="afl-sec-vigipirate-emblem">
<img
src={vigipirateImg}
alt="Vigipirate Urgence Attentat"
className="afl-sec-vigipirate-img"
/>
</div>
</div>
{/* COLONNE DROITE : Bulle Alerte 3117 et textes de consigne */}
<div className="afl-sec-right-col">
{/* Bulle d'alerte avec picto téléphone */}
<div className="afl-sec-speech-bubble-wrapper">
<div className="afl-sec-speech-bubble">
<div className="afl-sec-bubble-title">ALERTE SNCF</div>
<div className="afl-sec-bubble-numbers">
<div className="afl-sec-bubble-row">
<span className="afl-sec-num-lbl">TÉL.</span>
<span className="afl-sec-num-val">3117*</span>
</div>
<div className="afl-sec-bubble-row">
<span className="afl-sec-num-lbl">SMS</span>
<span className="afl-sec-num-val">31177**</span>
</div>
</div>
{/* Pointe de la bulle */}
<div className="afl-sec-bubble-tail" />
</div>
{/* Picto Téléphone Portable Rouge */}
<div className="afl-sec-phone-badge">
<svg viewBox="0 0 60 90" width="70px" height="100px" fill="none">
<rect x="5" y="5" width="50" height="80" rx="10" fill="#d9241c" stroke="#ffffff" strokeWidth="3.5" />
{/* Écran blanc avec point d'exclamation rouge */}
<rect x="12" y="14" width="36" height="42" rx="4" fill="#ffffff" />
<line x1="30" y1="22" x2="30" y2="40" stroke="#d9241c" strokeWidth="5" strokeLinecap="round" />
<circle cx="30" cy="49" r="3" fill="#d9241c" />
{/* Clavier / Touches */}
<circle cx="18" cy="64" r="2.2" fill="#ffffff" />
<circle cx="30" cy="64" r="2.2" fill="#ffffff" />
<circle cx="42" cy="64" r="2.2" fill="#ffffff" />
<circle cx="18" cy="72" r="2.2" fill="#ffffff" />
<circle cx="30" cy="72" r="2.2" fill="#ffffff" />
<circle cx="42" cy="72" r="2.2" fill="#ffffff" />
</svg>
</div>
</div>
{/* Texte des consignes */}
<div className="afl-sec-vigipirate-text-group">
<p className="afl-sec-consigne-intro">
Si vous êtes témoin d'une situation qui présente un risque pour votre sécurité ou celle des autres voyageurs,
</p>
<p className="afl-sec-consigne-main">
APPELEZ LE 3117* ou ENVOYEZ UN SMS AU 31177**
</p>
<p className="afl-sec-consigne-hours">24h/24 • 7j/7</p>
<p className="afl-sec-consigne-app-title">
Téléchargez l'application ALERTE 3117
</p>
<p className="afl-sec-consigne-app-sub">
gratuite sur App Store et Google Play.
</p>
<div className="afl-sec-footnotes">
<div>* Appel enregistré / Numéro gratuit</div>
<div>** SMS enregistré / prix SMS de votre opérateur</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

@@ -650,7 +650,7 @@ export default function ClassiquesArrivees(){
} }
}; };
}); });
setHoraires(formatted.slice(0, defilementMode ? 20 : 10)); setHoraires(formatted);
setAllHoraires(formatted); setAllHoraires(formatted);
} }
} catch (e) { } catch (e) {
@@ -839,30 +839,54 @@ export default function ClassiquesArrivees(){
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
if (!h || !d) return false;
const pad = (n) => String(n).padStart(2, '0'); 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; const customDaysRaw = h.jours_personnalises;
if (!customDaysRaw) return false; if (customDaysRaw) {
let customDays = [];
let customDays = []; if (Array.isArray(customDaysRaw)) {
if (Array.isArray(customDaysRaw)) { customDays = customDaysRaw;
customDays = customDaysRaw; } else if (typeof customDaysRaw === 'string') {
} else if (typeof customDaysRaw === 'string') { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
try { }
customDays = JSON.parse(customDaysRaw); if (Array.isArray(customDays) && customDays.length > 0) {
} catch (e) { const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact);
return false; if (match) return Boolean(match.circule);
} }
} }
if (Array.isArray(customDays)) { const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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_* // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_*
@@ -990,6 +1014,10 @@ export default function ClassiquesArrivees(){
let serviceDateStr; let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) { if (todayCandidates && todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); 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; serviceDateStr = todayStr;
} else { } else {
finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999));
@@ -647,7 +647,7 @@ export default function ClassiquesDeparts(){
causeDetails: t.cause_retard || null causeDetails: t.cause_retard || null
} }
})); }));
setHoraires(formatted.slice(0, defilementMode ? 20 : 10)); setHoraires(formatted);
setAllHoraires(formatted); setAllHoraires(formatted);
} }
} catch (e) { } catch (e) {
@@ -836,30 +836,54 @@ export default function ClassiquesDeparts(){
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
if (!h || !d) return false;
const pad = (n) => String(n).padStart(2, '0'); 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; const customDaysRaw = h.jours_personnalises;
if (!customDaysRaw) return false; if (customDaysRaw) {
let customDays = [];
let customDays = []; if (Array.isArray(customDaysRaw)) {
if (Array.isArray(customDaysRaw)) { customDays = customDaysRaw;
customDays = customDaysRaw; } else if (typeof customDaysRaw === 'string') {
} else if (typeof customDaysRaw === 'string') { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
try { }
customDays = JSON.parse(customDaysRaw); if (Array.isArray(customDays) && customDays.length > 0) {
} catch (e) { const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetDateCompact);
return false; if (match) return Boolean(match.circule);
} }
} }
if (Array.isArray(customDays)) { const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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_* // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_*
@@ -987,6 +1011,10 @@ export default function ClassiquesDeparts(){
let serviceDateStr; let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) { if (todayCandidates && todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); 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; serviceDateStr = todayStr;
} else { } else {
finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999)); finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._depHM||99999)-(b._depHM||99999));
@@ -80,30 +80,54 @@ export default function ClassiquesArriveesQuais() {
const [, setTravaux] = useState([]); const [, setTravaux] = useState([]);
const circulatesOn = useCallback((h, d) => { const circulatesOn = useCallback((h, d) => {
const pad = (n) => String(n).padStart(2, '0'); if (!h || !d) return false;
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; 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 (Array.isArray(h.dates) && h.dates.length > 0) {
if (!customDaysRaw) return false; return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen);
}
let customDays = []; const customDaysRaw = h.jours_personnalises;
if (Array.isArray(customDaysRaw)) { if (customDaysRaw) {
customDays = customDaysRaw; let customDays = [];
} else if (typeof customDaysRaw === 'string') { if (Array.isArray(customDaysRaw)) {
try { customDays = customDaysRaw;
customDays = JSON.parse(customDaysRaw); } else if (typeof customDaysRaw === 'string') {
} catch (e) { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
return false; }
} 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 dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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;
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) => { const normalizeName = useCallback((s) => {
@@ -80,30 +80,54 @@ export default function ClassiquesDepartsQuais() {
const [, setTravaux] = useState([]); const [, setTravaux] = useState([]);
const circulatesOn = useCallback((h, d) => { const circulatesOn = useCallback((h, d) => {
const pad = (n) => String(n).padStart(2, '0'); if (!h || !d) return false;
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; 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 (Array.isArray(h.dates) && h.dates.length > 0) {
if (!customDaysRaw) return false; return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen);
}
let customDays = []; const customDaysRaw = h.jours_personnalises;
if (Array.isArray(customDaysRaw)) { if (customDaysRaw) {
customDays = customDaysRaw; let customDays = [];
} else if (typeof customDaysRaw === 'string') { if (Array.isArray(customDaysRaw)) {
try { customDays = customDaysRaw;
customDays = JSON.parse(customDaysRaw); } else if (typeof customDaysRaw === 'string') {
} catch (e) { try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
return false; }
} 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 dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const match = customDays.find(item => item.date === targetDateStr); const colName = `circule_${dayNames[targetDay]}`;
if (match) { if (typeof h[colName] !== 'undefined' && h[colName] !== null) {
return Boolean(match.circule); 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;
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) => { const normalizeName = useCallback((s) => {
+59 -39
View File
@@ -887,7 +887,7 @@ export default function EVAArrivees() {
} }
}; };
}); });
setHoraires(formatted.slice(0, 10)); setHoraires(formatted);
setAllHoraires(formatted); setAllHoraires(formatted);
} }
} catch (e) { } catch (e) {
@@ -1032,21 +1032,55 @@ export default function EVAArrivees() {
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; if (!h || !d) return false;
const customDaysRaw = h.jours_personnalises; const pad = (n) => String(n).padStart(2, '0');
if (!customDaysRaw) return false; const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
let customDays = []; const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
if (Array.isArray(customDaysRaw)) { const targetDay = d.getDay();
customDays = customDaysRaw;
} else if (typeof customDaysRaw === 'string') { if (Array.isArray(h.dates) && h.dates.length > 0) {
try { customDays = JSON.parse(customDaysRaw); } catch (e) { return false; } return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen);
} }
if (Array.isArray(customDays)) {
const match = customDays.find(item => item.date === targetDateStr); const customDaysRaw = h.jours_personnalises;
if (match) return Boolean(match.circule); if (customDaysRaw) {
} let customDays = [];
return false; 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 travauxList = travauxData || [];
const travauxSubstList = travauxSubstData || []; const travauxSubstList = travauxSubstData || [];
@@ -1149,6 +1183,10 @@ export default function EVAArrivees() {
let serviceDateStr; let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) { if (todayCandidates && todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); 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; serviceDateStr = todayStr;
} else { } else {
finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999));
@@ -1314,28 +1352,10 @@ export default function EVAArrivees() {
'' ''
).toString().trim(); ).toString().trim();
const isCG = rawPlatform.toUpperCase() === '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));
rawPlatform.toUpperCase().includes('CG') || const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR');
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 || let time = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || '');
op.includes('CAR') ||
op.includes('AUTOCAR') ||
op.includes('BUS') ||
String(horaire.mode || '').toUpperCase().includes('BUS') ||
String(horaire.mode || '').toUpperCase().includes('CAR') ||
String(horaire.mode_transport || '').toUpperCase().includes('CAR') ||
String(horaire.mode_transport || '').toUpperCase().includes('BUS') ||
String(horaire.type_train || '').toUpperCase().includes('CAR') ||
String(horaire.type_train || '').toUpperCase().includes('BUS') ||
String(horaire.libelle_type_train || '').toUpperCase().includes('CAR') ||
String(horaire.libelle_type_train || '').toUpperCase().includes('BUS') ||
(travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero));
const time = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || '');
const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue'; const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue';
const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); const 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 */} {/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */}
<div className="eva-arr-col-right"> <div className="eva-arr-col-right">
{isCarTER ? ( {isSub || isCarTER ? (
<div className="eva-arr-bus-box"> <div className="eva-arr-bus-box">
<EVABusIcon className="eva-arr-bus-icon" /> <EVABusIcon className="eva-arr-bus-icon" />
</div> </div>
) : (rawPlatform && !isCG) ? ( ) : (rawPlatform && !isSuppressed) ? (
<div className="eva-arr-voie-box"> <div className="eva-arr-voie-box">
<span className="eva-arr-voie-label">Voie</span> <span className="eva-arr-voie-label">Voie</span>
<span className="eva-arr-voie-val">{rawPlatform}</span> <span className="eva-arr-voie-val">{rawPlatform}</span>
+59 -40
View File
@@ -880,7 +880,7 @@ export default function EVADeparts() {
causeDetails: t.cause_retard || null causeDetails: t.cause_retard || null
} }
})); }));
setHoraires(formatted.slice(0, 10)); setHoraires(formatted);
setAllHoraires(formatted); setAllHoraires(formatted);
} }
} catch (e) { } catch (e) {
@@ -1045,21 +1045,55 @@ export default function EVADeparts() {
const tomorrowStr = localDateStr(tomorrow); const tomorrowStr = localDateStr(tomorrow);
const circulatesOn = (h, d) => { const circulatesOn = (h, d) => {
const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; if (!h || !d) return false;
const customDaysRaw = h.jours_personnalises; const pad = (n) => String(n).padStart(2, '0');
if (!customDaysRaw) return false; const targetDateHyphen = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
let customDays = []; const targetDateCompact = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
if (Array.isArray(customDaysRaw)) { const targetDay = d.getDay();
customDays = customDaysRaw;
} else if (typeof customDaysRaw === 'string') { if (Array.isArray(h.dates) && h.dates.length > 0) {
try { customDays = JSON.parse(customDaysRaw); } catch (e) { return false; } return h.dates.includes(targetDateCompact) || h.dates.includes(targetDateHyphen);
} }
if (Array.isArray(customDays)) {
const match = customDays.find(item => item.date === targetDateStr); const customDaysRaw = h.jours_personnalises;
if (match) return Boolean(match.circule); if (customDaysRaw) {
} let customDays = [];
return false; 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 travauxList = travauxData || [];
const travauxSubstList = travauxSubstData || []; const travauxSubstList = travauxSubstData || [];
@@ -1162,6 +1196,10 @@ export default function EVADeparts() {
let serviceDateStr; let serviceDateStr;
if (todayCandidates && todayCandidates.length > 0) { if (todayCandidates && todayCandidates.length > 0) {
finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); 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; serviceDateStr = todayStr;
} else { } else {
finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999));
@@ -1313,7 +1351,6 @@ export default function EVADeparts() {
const horaire = h || {}; const horaire = h || {};
const op = (horaire.type_train || horaire.op || horaire.type || 'TER').toUpperCase(); const op = (horaire.type_train || horaire.op || horaire.type || 'TER').toUpperCase();
const number = horaire.numero_train || horaire.number || horaire.numero || ''; const number = horaire.numero_train || horaire.number || horaire.numero || '';
const rawPlatform = ( const rawPlatform = (
horaire._displayPlatform || horaire._displayPlatform ||
horaire.quai_reel || horaire.quai_reel ||
@@ -1325,28 +1362,10 @@ export default function EVADeparts() {
'' ''
).toString().trim(); ).toString().trim();
const isCG = rawPlatform.toUpperCase() === '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));
rawPlatform.toUpperCase().includes('CG') || const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR');
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 || let time = horaire._displayTime || formatTimeWithColon(horaire.heure_depart || horaire.time || horaire.heure || '');
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 || '');
const destination = horaire.gare_arrivee_nom || horaire.gare_arrivee || 'Destination inconnue'; 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); 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 */} {/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */}
<div className="eva-dep-col-right"> <div className="eva-dep-col-right">
{isCarTER ? ( {isSub || isCarTER ? (
<div className="eva-dep-bus-box"> <div className="eva-dep-bus-box">
<EVABusIcon className="eva-dep-bus-icon" /> <EVABusIcon className="eva-dep-bus-icon" />
</div> </div>
) : (rawPlatform && !isCG) ? ( ) : (rawPlatform && !isSuppressed) ? (
<div className="eva-dep-voie-box"> <div className="eva-dep-voie-box">
<span className="eva-dep-voie-label">Voie</span> <span className="eva-dep-voie-label">Voie</span>
<span className="eva-dep-voie-val">{rawPlatform}</span> <span className="eva-dep-voie-val">{rawPlatform}</span>
+811
View File
@@ -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<Array>} 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)
}))
}));
}
+2 -2
View File
@@ -24608,8 +24608,8 @@ const STATION_ALIASES = {
'aix tgv': '87319012', 'aix tgv': '87319012',
'aix en provence tgv': '87319012', 'aix en provence tgv': '87319012',
'aix centre': '87754002', 'aix centre': '87754002',
'besancon': '87713008', 'besancon': '87718007',
'besancon viotte': '87713008', 'besancon viotte': '87718007',
'besancon tgv': '87300863', 'besancon tgv': '87300863',
'besancon franche comte tgv': '87300863', 'besancon franche comte tgv': '87300863',
'dijon': '87713040', 'dijon': '87713040',
+106 -54
View File
@@ -341,49 +341,65 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
const currentDay = now.getDay(); // 0 = Dimanche, 1 = Lundi, ..., 6 = Samedi 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')}`; 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é) const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
let circulatingList = rawList.filter(t => { const tomorrowDay = tomorrow.getDay();
// Si la date courante figure dans les dates explicites const tomorrowYYYYMMDD = `${tomorrow.getFullYear()}${String(tomorrow.getMonth() + 1).padStart(2, '0')}${String(tomorrow.getDate()).padStart(2, '0')}`;
if (Array.isArray(t.dates) && t.dates.length > 0 && t.dates.includes(currentYYYYMMDD)) {
return true; // 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)) { // 2. Base Supabase : jours personnalisés
if ((!t.start || currentYYYYMMDD >= t.start) && (!t.end || currentYYYYMMDD <= t.end)) { const customDaysRaw = t.jours_personnalises;
return true; 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) { if (Array.isArray(t.days) && t.days.length > 0) {
const prevDay = (currentDay + 6) % 7; if (!t.days.includes(targetDay)) return false;
const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr); if (t.start && targetYYYYMMDD < String(t.start).replace(/-/g, '')) return false;
const m = String(rawTime).match(/^(\d{1,2})/); if (t.end && targetYYYYMMDD > String(t.end).replace(/-/g, '')) return false;
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);
}
return true; return true;
}); }
}
// 3. Fallback complet : si toujours vide, renvoyer la grille quotidienne complète // 5. Date unique fixée (date_depart, date, etc.)
if (circulatingList.length === 0) { const singleDate = t.date_depart || t.date || t.service_date;
circulatingList = rawList; 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 lookupPlatform = (trainNum, tripId, stationUic, stationName) => {
const cNum = cleanNum(trainNum); const cNum = cleanNum(trainNum);
const cName = stationName ? formatStationName(stationName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; 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 // Helper d'enrichissement d'un train
const enrichedTrains = circulatingList.map((t, idx) => { const mapTrain = (t, idx, isTomorrow = false) => {
const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr); const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr);
const timeHM = parseTimeHM(rawTime) || 0; const timeHM = parseTimeHM(rawTime) || 0;
// Recherche de mise à jour GTFS-RT ou SIRI-Lite (par trip_id ou numéro de train) // Recherche de mise à jour GTFS-RT ou SIRI-Lite (valide pour aujourd'hui)
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 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 = 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); 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 isCanceled = (rt && rt.isCanceled) || (siri && siri.isCanceled) || false;
const delayMinutes = (rt && rt.delayMinutes > 0) ? rt.delayMinutes : (siri && siri.delayMinutes > 0 ? siri.delayMinutes : 0); 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; const currentStationName = mode === 'arrivees' ? t.dest : t.orig;
let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName); let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName);
if (!realVoie && t.quai && typeof t.quai === 'string') { if (!realVoie && t.quai && typeof t.quai === 'string') {
@@ -431,13 +446,11 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
etat = 'retarde'; etat = 'retarde';
} }
// Causes de retard issues des alertes temps réel
let cause = ''; 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; 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 formattedStops = (t.stops || []).map((st) => {
const stopUic = st.uic || ''; const stopUic = st.uic || '';
const stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom); const stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom);
@@ -454,7 +467,7 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
}); });
return { return {
id: `gtfs-${t.num || idx}-${rawTime}-${idx}`, id: `gtfs-${isTomorrow ? 'demain-' : ''}${t.num || idx}-${rawTime}-${idx}`,
trip_id: t.trip_id, trip_id: t.trip_id,
numero_train: t.num || String(1000 + idx), numero_train: t.num || String(1000 + idx),
type_train: t.type || 'TER', type_train: t.type || 'TER',
@@ -477,6 +490,7 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
stops: formattedStops, stops: formattedStops,
composition: (t.type || '').includes('TGV') ? 'Rame 1 (Voitures 1 à 8) + Rame 2 (Voitures 11 à 18)' : 'Train standard', composition: (t.type || '').includes('TGV') ? 'Rame 1 (Voitures 1 à 8) + Rame 2 (Voitures 11 à 18)' : 'Train standard',
is_substitution: (t.type || '').includes('AUTOCAR'), is_substitution: (t.type || '').includes('AUTOCAR'),
_isTomorrow: isTomorrow,
_displayTime: formatTimeHM(rawTime), _displayTime: formatTimeHM(rawTime),
_displayPlatform: realVoie, _displayPlatform: realVoie,
_depHM: timeHM, _depHM: timeHM,
@@ -489,16 +503,54 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
causeDetails: cause || null causeDetails: cause || null
} }
}; };
}); };
// Tri chronologique : afficher les trains circulant à partir de l'heure courante // 1. Filtrer les trains circulant aujourd'hui
enrichedTrains.sort((a, b) => { let rawToday = rawList.filter(t => circulatesOnDate(t, currentDay, currentYYYYMMDD, now));
const aDiff = ((a._depHM || 0) - currentHM + 1440) % 1440; if (rawToday.length === 0) {
const bDiff = ((b._depHM || 0) - currentHM + 1440) % 1440; // Fallback si pas de calendrier GTFS explicite
return aDiff - bDiff; 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) { } catch (err) {
console.warn('[sncfApi] Erreur de chargement GTFS station:', err); console.warn('[sncfApi] Erreur de chargement GTFS station:', err);
return null; return null;