Compare commits

..
18 Commits
Author SHA1 Message Date
AdminGit 7334a2d9f7 V1.1.2.1 //
-> Conversion du temps de retard pour >=60 Min
-> Amélioration du rendu EVA (AFFICHEUR ABANDONNÉ PAR SNCF, Abandon possible)
-> AFFICHAGE DE TOUTES LES DONNÉES EN MÊME TEMPS
-> Correction erreurs de build
2026-10-07 22:51:11 +02:00
AdminGit 8f00da0896 V1.1.2 //
-> Conversion du temps de retard pour >=60 Min
-> Amélioration du rendu EVA (AFFICHEUR ABANDONNÉ PAR SNCF, Abandon possible)
-> AFFICHAGE DE TOUTES LES DONNÉES EN MÊME TEMPS
2026-10-07 22:43:36 +02:00
AdminGit 23828a54ef V1.1.1 //
-> Patch du bug du logo de l'horaire pas affiché. Corrigé.
2026-10-07 19:49:58 +02:00
AdminGit eb198cc727 V1.1.1 //
-> Patch du bug du logo de l'horaire pas affiché. Corrigé pour les Horaires TER à 6 numéros.
2026-10-07 17:14:40 +02:00
AdminGit 06cce59f82 V1.1.0 //
-> Refonte du design de sélection d'afficheurs
-> Ajout du Mode PiP
-> Patch du bug du logo de l'horaire pas affiché. Corrigé pour les Horaires TER à 6 numéros.
-> Patch problèmes lors du build de l'app
2026-10-07 16:13:43 +02:00
AdminGit d0de9978e8 V1.1.0 //
-> Refonte du design de sélection d'afficheurs
-> Ajout du Mode PiP
-> Patch du bug du logo de l'horaire pas affiché. Corrigé pour les Horaires TER à 6 numéros.
-> Patch problèmes lors du build de l'app
2026-10-07 15:46:05 +02:00
AdminGit 6cc1e1820d V1.1.0 //
-> Refonte du design de sélection d'afficheurs
-> Ajout du Mode PiP
-> Patch du bug du logo de l'horaire pas affiché. Corrigé pour les Horaires TER à 6 numéros.
2026-10-07 15:32:21 +02:00
AdminGit c30a0e9002 PATCH v1.0.1 //
-> Amélioration de la mise en relation des TYPES DE TRAINS PAR CODE DE CIRCULATION (BETA)
2026-10-07 01:10:54 +02:00
mathis d7d4f9cecf RELEASE V5.6.0 : Voici les modifications apportées :
-> REFONTE TOTALE DE TOUS LES AFFICHEURS CLASSIQUES + QUAIS + OPTION TGV

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

-> ADAPTABILITÉ EN FONCTION DES SITUATIONS
2026-10-02 22:53:37 +02:00
mathis d8f8ceaa9f RELEASE V5.6.0 : Voici les modifications apportées :
-> REFONTE TOTALE DE TOUS LES AFFICHEURS CLASSIQUES + QUAIS + OPTION TGV

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

-> ADAPTABILITÉ EN FONCTION DES SITUATIONS
2026-08-31 17:32:58 +02:00
mathis 9c6ac798f1 RELEASE V5.5.0 : Voici les modifications apportées :
-> REFONTE TOTALE DE TOUS LES AFFICHEURS CLASSIQUES + QUAIS

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

-> ADAPTABILITÉ EN FONCTION DES SITUATIONS
2026-08-31 02:02:46 +02:00
mathis b90c699302 fix(classiques): fine-tune text dimensions and hierarchy strictly according to SNCF display reference img 1 2026-08-31 00:24:21 +02:00
mathis e351398fe6 fix(classiques): align text styles, weights, and box shapes to strictly match SNCF display img 1 2026-08-31 00:04:48 +02:00
mathis 5d954af44a feat(classiques): enforce uniform physical scroll speed across all marquees and match clock box design from img 1 2026-08-31 00:02:39 +02:00
mathis 75d0a05f21 fix(typography): embed official Achemine fonts in src/fonts, calibrate font weights and sizes to strictly match SNCF display img 1 2026-08-30 23:56:22 +02:00
mathis a084f699ac fix(classiques): match clock design from img 1, remove leading bullet before 1st stop, set stops color to light gray #d0d8e2 2026-08-30 23:53:14 +02:00
mathis 4df19e55e7 fix(classiques): increase stops font size to 66px bold and slow down marquee scroll duration to 38s 2026-08-30 23:48:13 +02:00
mathis 647ec37d99 fix(classiques): reduce stops marquee scroll speed by 60% (duration 25s) 2026-08-30 23:46:54 +02:00
93 changed files with 9841 additions and 4150 deletions

No files matched your search

+2 -1
View File
@@ -1,2 +1,3 @@
REACT_APP_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr REACT_APP_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
REACT_APP_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg3NjY3MjMxLCJleHAiOjE5NDUzNDcyMzF9.uD7YUlOFIhK_Jqbra94o5-N5zdrzKezoCxV067RJi78 REACT_APP_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzkwNzY2MzE5LCJleHAiOjE5NDg0NDYzMTl9.N1znIiUGg8txVhd2Kbf019uo4AcM-3yfumRaZHR8ymE
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_6dB95QNDKiHcFD81TJETqq_3Mu5rWWU
-80
View File
@@ -1,80 +0,0 @@
# 🚅 Afficheurs Ferrovia
Application web de téléaffichage ferroviaire et d'information voyageurs en temps réel (PIDS - Passenger Information Display Systems), reproduisant fidèlement les écrans d'affichage des gares françaises (SNCF).
---
## ✨ Fonctionnalités
- **📡 Données Réelles & Complètes :**
- Horaires théoriques issus de l'archive officielle **GTFS SNCF** (3 507 gares, 41 847 circulations : TGV INOUI, OUIGO, TER, INTERCITÉS, ICE, TGV Lyria).
- Retards, perturbations et suppressions en direct via **GTFS-RT**.
- Détection et affichage des **voies et quais réels** via les flux **SIRI-Lite**.
- **🖥️ Multiples Générations d'Afficheurs :**
- **AFL (Afficheurs Fluides & Lumineux) :** Écrans modernes pour halls et quais (Départs / Arrivées).
- **Classiques :** Écrans d'affichage historiques (Départs, Arrivées, Écrans de Quai avec liste des arrêts desservis).
- **EVA :** Écrans vidéo voyageurs enrichis.
- **🔊 Module d'Annonces Sonores :** Jingles et annonces synthétiques en temps réel.
- **🔍 Recherche Intelligente des Gares :** Reconnaissance instantanée des gares, codes UIC, variantes d'écriture et abréviations (`Valence-TGV`, `Paris-Montparnasse`, `Lyon-Part-Dieu`, etc.).
---
## 📋 Patchnotes / Historique des Versions
Pour consulter le journal détaillé de toutes les versions, consultez le fichier **[CHANGELOG.md](./CHANGELOG.md)**.
<details open>
<summary><h3>🚀 Version 4.0.0 (V4) - <em>Dernière version</em></h3></summary>
- **Intégration GTFS & GTFS-RT Exhaustive :** 3 507 gares et 41 847 circulations réelles intégrées sans données mockées.
- **Quais Réels SIRI-Lite :** Extraction en continu des quais réels avec isolation stricte par gare sélectionnée et par arrêt.
- **Recherche Intelligente :** Algorithme tolérant aux tirets, accents et alias pour tous les noms de gares.
- **Normalisation des Horaires :** Prise en charge des trains de nuit et horaires modulo 24h.
- **Codebase Propre :** Résolution complète de tous les avertissements ESLint (`Compiled successfully`).
</details>
<details>
<summary><h3>🚆 Version 3.0.0 (V3)</h3></summary>
- Nouveaux types d'afficheurs AFL, Classiques et EVA.
- Module d'annonces sonores en temps réel.
- Gestion avancée des trains combinés / multi-tranches et des substitutions par autocar.
</details>
<details>
<summary><h3>🚉 Version 2.0.0 (V2)</h3></summary>
- Écrans dédiés par voie et quai avec liste déroulante des arrêts aval.
- Alternance dynamique des états de circulation et synchronisation automatique.
</details>
<details>
<summary><h3>🏁 Version 1.0.0 (V1)</h3></summary>
- Lancement initial de la plateforme avec horloge temps réel et reproduction de la signalétique ferroviaire.
</details>
---
## 🛠️ Installation & Démarrage
```bash
# Installer les dépendances
npm install
# Lancer le serveur de développement (http://localhost:3000)
npm start
# Compiler pour la production
npm run build
```
---
## 📄 Licence
Projet développé pour la simulation et la restitution d'écrans d'information ferroviaire. Données fournies sous licence ouverte via transport.data.gouv.fr et SNCF Open Data.
+1005
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "afficeheurs-ferrovia.", "name": "afficheurs-ferrovia",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "afficeheurs-ferrovia.", "name": "afficheurs-ferrovia",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"dependencies": "^0.0.1", "dependencies": "^0.0.1",
+1 -1
View File
@@ -23,7 +23,7 @@
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "PORT=4010 react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"serve": "serve -s build -l 4010", "serve": "serve -s build -l 4010",
"start:prod": "serve -s build -l 4010", "start:prod": "serve -s build -l 4010",
-35
View File
@@ -1,35 +0,0 @@
const fs = require('fs');
const path = require('path');
const files = [
{ p: 'src/pages/afficheurs/afl/AFLDeparts.js', arr: false },
{ p: 'src/pages/afficheurs/afl/AFLArrivees.js', arr: true },
{ p: 'src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js', arr: false },
{ p: 'src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js', arr: true },
{ p: 'src/pages/afficheurs/afl/quais/AFLDepartsQuais.js', arr: false },
{ p: 'src/pages/afficheurs/afl/quais/AFLArriveesQuais.js', arr: true },
];
for (const { p, arr } of files) {
const fullPath = path.join(process.cwd(), p);
if (!fs.existsSync(fullPath)) {
console.log(`File not found: ${fullPath}`);
continue;
}
let content = fs.readFileSync(fullPath, 'utf8');
if (!content.includes('import useAutoAnnonce from')) {
content = content.replace(
"import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';",
"import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';\nimport useAutoAnnonce from '../../../hooks/useAutoAnnonce';"
);
}
if (!content.includes('useAutoAnnonce(')) {
const hookCall = ` useAnnonceSonoreRealtime(resolvedGareId);\n useAutoAnnonce(horaires, currentTime, ${arr});`;
content = content.replace(" useAnnonceSonoreRealtime(resolvedGareId);", hookCall);
}
fs.writeFileSync(fullPath, content, 'utf8');
console.log(`Patched ${p}`);
}
+61
View File
@@ -0,0 +1,61 @@
# Plan d'implémentation : Correction de l'affichage des types de trains (TER Régionaux)
## Problématique analysée
Les données brutes issues des flux GTFS de la SNCF indiquent souvent simplement le type commercial générique `"TER"`, sans préciser la marque régionale (Mobigo, liO, Rémi, Hauts-de-France, etc.).
Actuellement, l'application tente de déduire ces marques via un patch "en dur" dans `src/utils/trainLogoHelper.js` pour Mobigo (`startsWith('89')` ou `startsWith('84')`).
Ce patch est **erroné** :
1. La série `84xxxx` englobe les trains des Hauts-de-France (ex: `841xxx`, `844947` sur Dunkerque/Valenciennes). C'est ce qui cause l'affichage "MOBIGO" en erreur sur l'Image 1.
2. L'Image 2 montre des trains d'Occitanie (876406) affichés comme "Rémi" et des confusions entre tranches. L'absence d'identification stricte basée sur un référentiel fiable entraîne des conflits.
## Contraintes respectées
- **Pas de variables locales / géographiques** : Suppression totale ou non-utilisation des listes de villes associées à des régions (ex: `detectRegionByRouteScore` qui associe 'Dunkerque' à HDF).
- **Utilisation du numéro de circulation de manière fiable** : Nous nous basons **strictement** sur le référentiel d'EspaceRails fourni pour l'immatriculation des TER.
## Actions prévues (Implémentation)
### 1. Refonte du fallback régional dans `trainLogoHelper.js`
Nous allons remplacer la condition codée en dur (et fausse) pour "MOBIGO" par un utilitaire de classification exhaustif basé fidèlement sur les données d'EspaceRails. Cette fonction sera appelée si le train est identifié comme un `TER` générique.
### 2. Plan de numérotation déterministe (Le "Dictionnaire" EspaceRails)
La fonction extraira le numéro (ex: `844947` => préfixe `844`) et appliquera le tableau de correspondance suivant, construit à partir de votre source :
* **Grand Est (Fluo)**
* *Alsace* : 830 à 832, 160
* *Lorraine* : 833 à 837, 161
* *Champagne-Ardenne* : 838 à 840, 162
* **Hauts-de-France**
* *Nord-Pas-de-Calais* : 841 à 846, 163 à 164
* *Picardie* : 847 à 849, 164
* **Normandie (Nomad)**
* *Haute-Normandie* : 850 à 851, 165
* *Basse-Normandie* : 852 à 853, 165
* **Bretagne (BreizhGo)** : 854 à 856, 166
* **Pays de la Loire (Aléop)** : 857 à 859, 166 à 167
* **Centre-Val de Loire (Rémi)** : 860 à 862, 167 à 168
* **Nouvelle-Aquitaine**
* *Poitou-Charentes* : 863 à 864, 168
* *Aquitaine* : 865 à 867, 169
* *Limousin* : 868 à 869, 170
* **Occitanie (liO)**
* *Midi-Pyrénées* : 870 à 872, 171
* *Languedoc-Roussillon* : 876 à 878, 173
* **Auvergne-Rhône-Alpes**
* *Auvergne* : 873 à 875, 172
* *Rhône-Alpes* : 882 à 890, 175 à 179
* **Provence-Alpes-Côte d'Azur (ZOU)** : 879 à 881, 173 à 174
* **Bourgogne-Franche-Comté (Mobigo)**
* *Bourgogne* : 891 à 893, 177 à 178
* *Franche-Comté* : 894 à 895, 178
*(Ce référentiel montre bien que "841" est en Hauts-de-France (NPDC), ce qui corrige l'Img 1, et que "876" est bien Languedoc-Roussillon (liO), ce qui corrige l'Img 2).*
### 3. Application dans `getTrainLogoAndMeta`
Si l'opérateur brut du GTFS est un terme générique (comme `"TER"`), la fonction :
1. Extraira le numéro à 5 ou 6 chiffres.
2. Analysera les 3 premiers chiffres (ex: `844`).
3. Consultera le dictionnaire déterministe EspaceRails.
4. Remplacera le `typeLabel` et associera le bon logo via `logoLookup`.
> Cela garantit la fiabilité demandée, supprime l'usage de variables locales "villes", et solutionne définitivement les bugs d'affichage des Images 1 et 2 en s'appuyant sur les préfixes documentés.
Validez-vous l'intégration de cette logique dans `src/utils/trainLogoHelper.js` ?
+77
View File
@@ -0,0 +1,77 @@
const CACHE_NAME = 'ferrovia-pwa-v2';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || !event.request.url.startsWith(self.location.origin)) {
return;
}
// Pour la navigation (HTML / pages), stratégie Network-First systématique pour toujours charger la dernière version
const isNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') && event.request.headers.get('accept').includes('text/html'));
if (isNavigation) {
event.respondWith(
fetch(event.request)
.then((networkResponse) => {
if (networkResponse && networkResponse.ok) {
const clone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return networkResponse;
})
.catch(() => {
return caches.match(event.request).then((cached) => cached || caches.match('/index.html') || caches.match('./index.html'));
})
);
return;
}
// Pour les scripts JS et CSS, Network-First pour éviter le gel des bundles
const isCodeAsset = event.request.url.includes('/static/js/') || event.request.url.includes('/static/css/');
if (isCodeAsset) {
event.respondWith(
fetch(event.request)
.then((networkResponse) => {
if (networkResponse && networkResponse.ok) {
const clone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return networkResponse;
})
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// Pour les autres ressources (images, favicons, polices), Cache-First avec mise en cache réseau
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.ok) {
const clone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return networkResponse;
});
})
);
});
+67 -66
View File
@@ -1,381 +1,382 @@
{ {
"generatedAt": "2025-10-24T21:56:22.827Z", "generatedAt": "2026-10-24T21:56:22.827Z",
"logos": [ "logos": [
{ {
"file": "logo-aleop.svg", "file": "logo-aleop.svg",
"path": "/img/type/logo-aleop.svg", "path": "/logo-aleop.svg",
"slug": "aleop", "slug": "aleop",
"name": "Aleop" "name": "Aleop"
}, },
{ {
"file": "logo-auvergerhonealpes.svg", "file": "logo-auvergerhonealpes.svg",
"path": "/img/type/logo-auvergerhonealpes.svg", "path": "/logo-auvergerhonealpes.svg",
"slug": "auvergerhonealpes", "slug": "auvergerhonealpes",
"name": "Auvergerhonealpes" "name": "TER AURA"
}, },
{ {
"file": "logo-breizhgo.svg", "file": "logo-breizhgo.svg",
"path": "/img/type/logo-breizhgo.svg", "path": "/logo-breizhgo.svg",,
"slug": "breizhgo", "slug": "breizhgo",
"name": "Breizhgo" "name": "Breizhgo"
}, },
{ {
"file": "logo-cfl.svg", "file": "logo-cfl.svg",
"path": "/img/type/logo-cfl.svg", "path": "/logo-cfl.svg",
"slug": "cfl", "slug": "cfl",
"name": "CFL" "name": "CFL"
}, },
{ {
"file": "logo-db.svg", "file": "logo-db.svg",
"path": "/img/type/logo-db.svg", "path": "/logo-db.svg",
"slug": "db", "slug": "db",
"name": "DB" "name": "DB"
}, },
{ {
"file": "logo-eurostar.svg", "file": "logo-eurostar.svg",
"path": "/img/type/logo-eurostar.svg", "path": "/logo-eurostar.svg",
"slug": "eurostar", "slug": "eurostar",
"name": "Eurostar" "name": "Eurostar"
}, },
{ {
"file": "logo-fluo.svg", "file": "logo-fluo.svg",
"path": "/img/type/logo-fluo.svg", "path": "/logo-fluo.svg",
"slug": "fluo", "slug": "fluo",
"name": "Fluo" "name": "Fluo"
}, },
{ {
"file": "logo-frecciarossa.svg", "file": "logo-frecciarossa.svg",
"path": "/img/type/logo-frecciarossa.svg", "path": "/logo-frecciarossa.svg",
"slug": "frecciarossa", "slug": "frecciarossa",
"name": "Frecciarossa" "name": "Frecciarossa"
}, },
{ {
"file": "logo-hautsdefrance.svg", "file": "logo-hautsdefrance.svg",
"path": "/img/type/logo-hautsdefrance.svg", "path": "/logo-hautsdefrance.svg",
"slug": "hautsdefrance", "slug": "hautsdefrance",
"name": "Hautsdefrance" "name": "Hautsdefrance"
}, },
{ {
"file": "logo-ice.svg", "file": "logo-ice.svg",
"path": "/img/type/logo-ice.svg", "path": "/logo-ice.svg",
"slug": "ice", "slug": "ice",
"name": "ICE" "name": "ICE"
}, },
{ {
"file": "logo-intercites.svg", "file": "logo-intercites.svg",
"path": "/img/type/logo-intercites.svg", "path": "/logo-intercites.svg",
"slug": "intercites", "slug": "intercites",
"name": "Intercités" "name": "Intercités"
}, },
{ {
"file": "logo-ler.svg", "file": "logo-ler.svg",
"path": "/img/type/logo-ler.svg", "path": "/logo-ler.svg",
"slug": "ler", "slug": "ler",
"name": "Ler" "name": "Ler"
}, },
{ {
"file": "logo-lio.svg", "file": "logo-lio.svg",
"path": "/img/type/logo-lio.svg", "path": "/logo-lio.svg",
"slug": "lio", "slug": "lio",
"name": "Lio" "name": "Lio"
}, },
{ {
"file": "logo-lunea.svg", "file": "logo-lunea.svg",
"path": "/img/type/logo-lunea.svg", "path": "/logo-lunea.svg",
"slug": "lunea", "slug": "lunea",
"name": "Lunea" "name": "Lunea"
}, },
{ {
"file": "logo-metrolor.svg", "file": "logo-metrolor.svg",
"path": "/img/type/logo-metrolor.svg", "path": "/logo-metrolor.svg",
"slug": "metrolor", "slug": "metrolor",
"name": "Metrolor" "name": "Metrolor"
}, },
{ {
"file": "logo-mobigo.svg", "file": "logo-mobigo.svg",
"path": "/img/type/logo-mobigo.svg", "path": "/logo-mobigo.svg",
"slug": "mobigo", "slug": "mobigo",
"name": "Mobigo" "name": "Mobigo"
}, },
{ {
"file": "logo-nomad.svg", "file": "logo-nomad.svg",
"path": "/img/type/logo-nomad.svg", "path": "/logo-nomad.svg",
"slug": "nomad", "slug": "nomad",
"name": "Nomad" "name": "Nomad"
}, },
{ {
"file": "logo-occitanie.svg", "file": "logo-occitanie.svg",
"path": "/img/type/logo-occitanie.svg", "path": "/logo-occitanie.svg",
"slug": "occitanie", "slug": "occitanie",
"name": "Occitanie" "name": "Occitanie"
}, },
{ {
"file": "logo-ouigo.svg", "file": "logo-ouigo.svg",
"path": "/img/type/logo-ouigo.svg", "path": "/logo-ouigo.svg",
"slug": "ouigo", "slug": "ouigo",
"name": "TGV Ouigo" "name": "TGV Ouigo"
}, },
{ {
"file": "logo-ouigo-classique.svg", "file": "logo-ouigo-classique.svg",
"path": "/img/type/logo-ouigo-classique.svg", "path": "/logo-ouigo-classique.svg",
"slug": "ouigo-classique", "slug": "ouigo-classique",
"name": "OUIGO Trains Classiques" "name": "OUIGO Trains Classiques"
}, },
{ {
"file": "logo-remi.svg", "file": "logo-remi.svg",
"path": "/img/type/logo-remi.svg", "path": "/logo-remi.svg",
"slug": "remi", "slug": "remi",
"name": "Remi" "name": "Remi"
}, },
{ {
"file": "logo-renfeave.svg", "file": "logo-renfeave.svg",
"path": "/img/type/logo-renfeave.svg", "path": "/logo-renfeave.svg",
"slug": "renfeave", "slug": "renfeave",
"name": "Renfe AVE" "name": "Renfe AVE"
}, },
{ {
"file": "logo-rer-a.svg", "file": "logo-rer-a.svg",
"path": "/img/type/logo-rer-a.svg", "path": "/logo-rer-a.svg",
"slug": "rer-a", "slug": "rer-a",
"name": "RER A" "name": "RER A"
}, },
{ {
"file": "logo-rer-b.svg", "file": "logo-rer-b.svg",
"path": "/img/type/logo-rer-b.svg", "path": "/logo-rer-b.svg",
"slug": "rer-b", "slug": "rer-b",
"name": "RER B" "name": "RER B"
}, },
{ {
"file": "logo-rer-c.svg", "file": "logo-rer-c.svg",
"path": "/img/type/logo-rer-c.svg", "path": "/logo-rer-c.svg",
"slug": "rer-c", "slug": "rer-c",
"name": "RER C" "name": "RER C"
}, },
{ {
"file": "logo-rer-d.svg", "file": "logo-rer-d.svg",
"path": "/img/type/logo-rer-d.svg", "path": "/logo-rer-d.svg",
"slug": "rer-d", "slug": "rer-d",
"name": "RER D" "name": "RER D"
}, },
{ {
"file": "logo-rer-e.svg", "file": "logo-rer-e.svg",
"path": "/img/type/logo-rer-e.svg", "path": "/logo-rer-e.svg",
"slug": "rer-e", "slug": "rer-e",
"name": "RER E" "name": "RER E"
}, },
{ {
"file": "logo-sbb.svg", "file": "logo-sbb.svg",
"path": "/img/type/logo-sbb.svg", "path": "/logo-sbb.svg",
"slug": "sbb", "slug": "sbb",
"name": "SBB" "name": "SBB"
}, },
{ {
"file": "logo-sncb.svg", "file": "logo-sncb.svg",
"path": "/img/type/logo-sncb.svg", "path": "/logo-sncb.svg",
"slug": "sncb", "slug": "sncb",
"name": "SNCB" "name": "SNCB"
}, },
{ {
"file": "logo-sncf.svg", "file": "logo-sncf.svg",
"path": "/img/type/logo-sncf.svg", "path": "/logo-sncf.svg",
"slug": "sncf", "slug": "sncf",
"name": "Sncf" "name": "Sncf"
}, },
{ {
"file": "sncf-voyageurs-logo.png", "file": "sncf-voyageurs-logo.png",
"path": "/img/type/sncf-voyageurs-logo.png", "path": "/sncf-voyageurs-logo.png",
"slug": "sncf-voyageurs-logo", "slug": "sncf-voyageurs-logo",
"name": "SNCF" "name": "SNCF"
}, },
{ {
"file": "logo-sncf1985.svg", "file": "logo-sncf1985.svg",
"path": "/img/type/logo-sncf1985.svg", "path": "/logo-sncf1985.svg",
"slug": "sncf1985", "slug": "sncf1985",
"name": "Sncf1985" "name": "Sncf1985"
}, },
{ {
"file": "logo-sncf1992.svg", "file": "logo-sncf1992.svg",
"path": "/img/type/logo-sncf1992.svg", "path": "/logo-sncf1992.svg",
"slug": "sncf1992", "slug": "sncf1992",
"name": "Sncf1992" "name": "Sncf1992"
}, },
{ {
"file": "logo-t-h.svg", "file": "logo-t-h.svg",
"path": "/img/type/logo-t-h.svg", "path": "/logo-t-h.svg",
"slug": "t-h", "slug": "t-h",
"name": "T H" "name": "T H"
}, },
{ {
"file": "logo-t-j.svg", "file": "logo-t-j.svg",
"path": "/img/type/logo-t-j.svg", "path": "/logo-t-j.svg",
"slug": "t-j", "slug": "t-j",
"name": "T J" "name": "T J"
}, },
{ {
"file": "logo-t-k.svg", "file": "logo-t-k.svg",
"path": "/img/type/logo-t-k.svg", "path": "/logo-t-k.svg",
"slug": "t-k", "slug": "t-k",
"name": "T K" "name": "T K"
}, },
{ {
"file": "logo-t-l.svg", "file": "logo-t-l.svg",
"path": "/img/type/logo-t-l.svg", "path": "/logo-t-l.svg",
"slug": "t-l", "slug": "t-l",
"name": "T L" "name": "T L"
}, },
{ {
"file": "logo-t-n.svg", "file": "logo-t-n.svg",
"path": "/img/type/logo-t-n.svg", "path": "/logo-t-n.svg",
"slug": "t-n", "slug": "t-n",
"name": "T N" "name": "T N"
}, },
{ {
"file": "logo-t-p.svg", "file": "logo-t-p.svg",
"path": "/img/type/logo-t-p.svg", "path": "/logo-t-p.svg",
"slug": "t-p", "slug": "t-p",
"name": "T P" "name": "T P"
}, },
{ {
"file": "logo-t-r.svg", "file": "logo-t-r.svg",
"path": "/img/type/logo-t-r.svg", "path": "/logo-t-r.svg",
"slug": "t-r", "slug": "t-r",
"name": "T R" "name": "T R"
}, },
{ {
"file": "logo-t-u.svg", "file": "logo-t-u.svg",
"path": "/img/type/logo-t-u.svg", "path": "/logo-t-u.svg",
"slug": "t-u", "slug": "t-u",
"name": "T U" "name": "T U"
}, },
{ {
"file": "logo-teoz.svg", "file": "logo-teoz.svg",
"path": "/img/type/logo-teoz.svg", "path": "/logo-teoz.svg",
"slug": "teoz", "slug": "teoz",
"name": "Teoz" "name": "Teoz"
}, },
{ {
"file": "sncf-voyageurs-logo.png", "file": "sncf-voyageurs-logo.png",
"path": "/img/type/sncf-voyageurs-logo.png", "path": "/sncf-voyageurs-logo.png",
"slug": "ter", "slug": "TER",
"name": "TER" "name": "TER"
}, },
{ {
"file": "logo-ter-rhone-alpes.svg", "file": "logo-ter-rhone-alpes.svg",
"path": "/img/type/logo-ter-rhone-alpes.svg", "path": "/logo-ter-rhone-alpes.svg",
"slug": "ter-rhone-alpes", "slug": "ter-rhone-alpes",
"name": "TER Rhone-Alpes" "name": "TER Rhone-Alpes"
}, },
{ {
"file": "logo-teralsace.svg", "file": "logo-teralsace.svg",
"path": "/img/type/logo-teralsace.svg", "path": "/logo-teralsace.svg",
"slug": "teralsace", "slug": "teralsace",
"name": "Ter Alsace" "name": "Ter Alsace"
}, },
{ {
"file": "logo-teraquitaine.svg", "file": "logo-teraquitaine.svg",
"path": "/img/type/logo-teraquitaine.svg", "path": "/logo-teraquitaine.svg",
"slug": "teraquitaine", "slug": "teraquitaine",
"name": "Ter Aquitaine" "name": "Ter Aquitaine"
}, },
{ {
"file": "logo-terbassenormandie.svg", "file": "logo-terbassenormandie.svg",
"path": "/img/type/logo-terbassenormandie.svg", "path": "/logo-terbassenormandie.svg",
"slug": "terbassenormandie", "slug": "terbassenormandie",
"name": "Ter Basse Normandie" "name": "Ter Basse Normandie"
}, },
{ {
"file": "logo-terbourgogne.svg", "file": "logo-terbourgogne.svg",
"path": "/img/type/logo-terbourgogne.svg", "path": "/logo-terbourgogne.svg",
"slug": "terbourgogne", "slug": "terbourgogne",
"name": "Ter Bourgogne" "name": "Ter Bourgogne"
}, },
{ {
"file": "logo-terbretagne.svg", "file": "logo-terbretagne.svg",
"path": "/img/type/logo-terbretagne.svg", "path": "/logo-terbretagne.svg",
"slug": "terbretagne", "slug": "terbretagne",
"name": "Ter Bretagne" "name": "Ter Bretagne"
}, },
{ {
"file": "logo-tercentre.svg", "file": "logo-tercentre.svg",
"path": "/img/type/logo-tercentre.svg", "path": "/logo-tercentre.svg",
"slug": "tercentre", "slug": "tercentre",
"name": "Ter Centre" "name": "Ter Centre"
}, },
{ {
"file": "logo-terlanguedocroussillon.svg", "file": "logo-terlanguedocroussillon.svg",
"path": "/img/type/logo-terlanguedocroussillon.svg", "path": "/logo-terlanguedocroussillon.svg",
"slug": "terlanguedocroussillon", "slug": "terlanguedocroussillon",
"name": "Ter Languedoc-Roussillon" "name": "Ter Languedoc-Roussillon"
}, },
{ {
"file": "logo-termidipyrenees.svg", "file": "logo-termidipyrenees.svg",
"path": "/img/type/logo-termidipyrenees.svg", "path": "/logo-termidipyrenees.svg",
"slug": "termidipyrenees", "slug": "termidipyrenees",
"name": "Ter Midi-Pyrenees" "name": "Ter Midi-Pyrenees"
}, },
{ {
"file": "logo-ternordpasdecalais.svg", "file": "logo-ternordpasdecalais.svg",
"path": "/img/type/logo-ternordpasdecalais.svg", "path": "/logo-ternordpasdecalais.svg",
"slug": "ternordpasdecalais", "slug": "ternordpasdecalais",
"name": "Ter Nord-pas-de-Calais" "name": "Ter Nord-pas-de-Calais"
}, },
{ {
"file": "logo-terpoitoucharentes.svg", "file": "logo-terpoitoucharentes.svg",
"path": "/img/type/logo-terpoitoucharentes.svg", "path": "/logo-terpoitoucharentes.svg",
"slug": "terpoitoucharentes", "slug": "terpoitoucharentes",
"name": "Ter Poitou Charentes" "name": "Ter Poitou Charentes"
}, },
{ {
"file": "logo-tgv.svg", "file": "logo-tgv.svg",
"path": "/img/type/logo-tgv.svg", "path": "/logo-tgv.svg",
"slug": "tgv", "slug": "tgv",
"name": "TGV" "name": "TGV"
}, },
{ {
"file": "logo-inoui.svg", "file": "logo-inoui.svg",
"path": "/img/type/logo-inoui.svg", "path": "/logo-inoui.svg",
"slug": "inoui", "slug": "inoui",
"name": "TGV InOui" "name": "TGV InOui"
}, },
{ {
"file": "logo-lyria.svg", "file": "logo-lyria.svg",
"path": "/img/type/logo-lyria.svg", "path": "/logo-lyria.svg",
"slug": "lyria", "slug": "lyria",
"name": "TGV Lyria" "name": "TGV Lyria"
}, },
{ {
"file": "logo-thalys.svg", "file": "logo-thalys.svg",
"path": "/img/type/logo-thalys.svg", "path": "/logo-thalys.svg",
"slug": "thalys", "slug": "thalys",
"name": "Thalys" "name": "Thalys"
}, },
{ {
"file": "logo-thello.svg", "file": "logo-thello.svg",
"path": "/img/type/logo-thello.svg", "path": "/logo-thello.svg",
"slug": "thello", "slug": "thello",
"name": "Thello" "name": "Thello"
}, },
{ {
"file": "track-car.svg", "file": "track-car.svg",
"path": "/img/type/track-car.svg", "path": "/track-car.svg",
"slug": "track-car", "slug": "track-car",
"name": "Car TER" "name": "Car TER"
}, },
{ {
"file": "logo-tramtrain.svg", "file": "logo-tramtrain.svg",
"path": "/img/type/logo-tramtrain.svg", "path": "/logo-tramtrain.svg",
"slug": "tramtrain", "slug": "tramtrain",
"name": "Tramtrain" "name": "Tramtrain"
}, },
{ {
"file": "logo-trenitalia.svg", "file": "logo-trenitalia.svg",
"path": "/img/type/logo-trenitalia.svg", "path": "/logo-trenitalia.svg",
"slug": "trenitalia", "slug": "trenitalia",
"name": "Trenitalia" "name": "Trenitalia"
}, },
{ {
"file": "logo-zou.svg", "file": "logo-zou.svg",
"path": "/img/type/logo-zou.svg", "path": "/logo-zou.svg",
"slug": "zou", "slug": "zou",
"name": "Zou" "name": "Zou"
} }
+51 -51
View File
@@ -3,181 +3,181 @@
"logos": [ "logos": [
{ {
"file": "logo-aleop-color.svg", "file": "logo-aleop-color.svg",
"path": "/img/type/logo-aleop-color.svg", "path": "/logo-aleop-color.svg",
"slug": "aleop", "slug": "aleop",
"name": "Aleop" "name": "Aleop"
}, },
{ {
"file": "logo-auvergerhonealpes-color.svg", "file": "logo-auvergerhonealpes-color.svg",
"path": "/img/type/logo-auvergerhonealpes-color.svg", "path": "/logo-auvergerhonealpes-color.svg",
"slug": "auvergerhonealpes", "slug": "auvergerhonealpes",
"name": "Auvergerhonealpes" "name": "Auvergerhonealpes"
}, },
{ {
"file": "logo-breizhgo-color.svg", "file": "logo-breizhgo-color.svg",
"path": "/img/type/logo-breizhgo-color.svg", "path": "/logo-breizhgo-color.svg",
"slug": "breizhgo", "slug": "breizhgo",
"name": "Breizhgo" "name": "Breizhgo"
}, },
{ {
"file": "logo-cfl-color.svg", "file": "logo-cfl-color.svg",
"path": "/img/type/logo-cfl-color.svg", "path": "/logo-cfl-color.svg",
"slug": "cfl", "slug": "cfl",
"name": "CFL" "name": "CFL"
}, },
{ {
"file": "logo-db-color.svg", "file": "logo-db-color.svg",
"path": "/img/type/logo-db-color.svg", "path": "/logo-db-color.svg",
"slug": "db", "slug": "db",
"name": "DB" "name": "DB"
}, },
{ {
"file": "logo-eurostar-color.svg", "file": "logo-eurostar-color.svg",
"path": "/img/type/logo-eurostar-color.svg", "path": "/logo-eurostar-color.svg",
"slug": "eurostar", "slug": "eurostar",
"name": "Eurostar" "name": "Eurostar"
}, },
{ {
"file": "logo-fluo-color.svg", "file": "logo-fluo-color.svg",
"path": "/img/type/logo-fluo-color.svg", "path": "/logo-fluo-color.svg",
"slug": "fluo", "slug": "fluo",
"name": "Fluo" "name": "Fluo"
}, },
{ {
"file": "logo-frecciarossa-color.svg", "file": "logo-frecciarossa-color.svg",
"path": "/img/type/logo-frecciarossa-color.svg", "path": "/logo-frecciarossa-color.svg",
"slug": "frecciarossa", "slug": "frecciarossa",
"name": "Frecciarossa" "name": "Frecciarossa"
}, },
{ {
"file": "logo-hautsdefrance-color.svg", "file": "logo-hautsdefrance-color.svg",
"path": "/img/type/logo-hautsdefrance-color.svg", "path": "/logo-hautsdefrance-color.svg",
"slug": "hautsdefrance", "slug": "hautsdefrance",
"name": "Hautsdefrance" "name": "Hautsdefrance"
}, },
{ {
"file": "logo-ice-color.svg", "file": "logo-ice-color.svg",
"path": "/img/type/logo-ice-color.svg", "path": "/logo-ice-color.svg",
"slug": "ice", "slug": "ice",
"name": "ICE" "name": "ICE"
}, },
{ {
"file": "logo-intercites-color.svg", "file": "logo-intercites-color.svg",
"path": "/img/type/logo-intercites-color.svg", "path": "/logo-intercites-color.svg",
"slug": "intercites", "slug": "intercites",
"name": "Intercités" "name": "Intercités"
}, },
{ {
"file": "logo-ler-color.svg", "file": "logo-ler-color.svg",
"path": "/img/type/logo-ler-color.svg", "path": "/logo-ler-color.svg",
"slug": "ler", "slug": "ler",
"name": "Ler" "name": "Ler"
}, },
{ {
"file": "logo-lio-color.svg", "file": "logo-lio-color.svg",
"path": "/img/type/logo-lio-color.svg", "path": "/logo-lio-color.svg",
"slug": "lio", "slug": "lio",
"name": "Lio" "name": "Lio"
}, },
{ {
"file": "logo-lunea-color.svg", "file": "logo-lunea-color.svg",
"path": "/img/type/logo-lunea-color.svg", "path": "/logo-lunea-color.svg",
"slug": "lunea", "slug": "lunea",
"name": "Lunea" "name": "Lunea"
}, },
{ {
"file": "logo-metrolor-color.svg", "file": "logo-metrolor-color.svg",
"path": "/img/type/logo-metrolor-color.svg", "path": "/logo-metrolor-color.svg",
"slug": "metrolor", "slug": "metrolor",
"name": "Metrolor" "name": "Metrolor"
}, },
{ {
"file": "logo-mobigo-color.svg", "file": "logo-mobigo-color.svg",
"path": "/img/type/logo-mobigo-color.svg", "path": "/logo-mobigo-color.svg",
"slug": "mobigo", "slug": "mobigo",
"name": "Mobigo" "name": "Mobigo"
}, },
{ {
"file": "logo-nomad-color.svg", "file": "logo-nomad-color.svg",
"path": "/img/type/logo-nomad-color.svg", "path": "/logo-nomad-color.svg",
"slug": "nomad", "slug": "nomad",
"name": "Nomad" "name": "Nomad"
}, },
{ {
"file": "logo-occitanie-color.svg", "file": "logo-occitanie-color.svg",
"path": "/img/type/logo-occitanie-color.svg", "path": "/logo-occitanie-color.svg",
"slug": "occitanie", "slug": "occitanie",
"name": "Occitanie" "name": "Occitanie"
}, },
{ {
"file": "logo-ouigo-color.svg", "file": "logo-ouigo-color.svg",
"path": "/img/type/logo-ouigo-color.svg", "path": "/logo-ouigo-color.svg",
"slug": "ouigo", "slug": "ouigo",
"name": "OUIGO" "name": "OUIGO"
}, },
{ {
"file": "logo-ouigo-classique-color.svg", "file": "logo-ouigo-classique-color.svg",
"path": "/img/type/logo-ouigo-classique-color.svg", "path": "/logo-ouigo-classique-color.svg",
"slug": "ouigo-classique", "slug": "ouigo-classique",
"name": "OUIGO Trains Classiques" "name": "OUIGO Trains Classiques"
}, },
{ {
"file": "logo-remi-color.svg", "file": "logo-remi-color.svg",
"path": "/img/type/logo-remi-color.svg", "path": "/logo-remi-color.svg",
"slug": "remi", "slug": "remi",
"name": "Remi" "name": "Remi"
}, },
{ {
"file": "logo-renfeave-color.svg", "file": "logo-renfeave-color.svg",
"path": "/img/type/logo-renfeave-color.svg", "path": "/logo-renfeave-color.svg",
"slug": "renfeave", "slug": "renfeave",
"name": "Renfe AVE" "name": "Renfe AVE"
}, },
{ {
"file": "logo-rer-a-color.svg", "file": "logo-rer-a-color.svg",
"path": "/img/type/logo-rer-a-color.svg", "path": "/logo-rer-a-color.svg",
"slug": "rer-a", "slug": "rer-a",
"name": "RER A" "name": "RER A"
}, },
{ {
"file": "logo-rer-b-color.svg", "file": "logo-rer-b-color.svg",
"path": "/img/type/logo-rer-b-color.svg", "path": "/logo-rer-b-color.svg",
"slug": "rer-b", "slug": "rer-b",
"name": "RER B" "name": "RER B"
}, },
{ {
"file": "logo-rer-c-color.svg", "file": "logo-rer-c-color.svg",
"path": "/img/type/logo-rer-c-color.svg", "path": "/logo-rer-c-color.svg",
"slug": "rer-c", "slug": "rer-c",
"name": "RER C" "name": "RER C"
}, },
{ {
"file": "logo-rer-d-color.svg", "file": "logo-rer-d-color.svg",
"path": "/img/type/logo-rer-d-color.svg", "path": "/logo-rer-d-color.svg",
"slug": "rer-d", "slug": "rer-d",
"name": "RER D" "name": "RER D"
}, },
{ {
"file": "logo-rer-e-color.svg", "file": "logo-rer-e-color.svg",
"path": "/img/type/logo-rer-e-color.svg", "path": "/logo-rer-e-color.svg",
"slug": "rer-e", "slug": "rer-e",
"name": "RER E" "name": "RER E"
}, },
{ {
"file": "logo-sbb-color.svg", "file": "logo-sbb-color.svg",
"path": "/img/type/logo-sbb-color.svg", "path": "/logo-sbb-color.svg",
"slug": "sbb", "slug": "sbb",
"name": "SBB" "name": "SBB"
}, },
{ {
"file": "logo-sncb-color.svg", "file": "logo-sncb-color.svg",
"path": "/img/type/logo-sncb-color.svg", "path": "/logo-sncb-color.svg",
"slug": "sncb", "slug": "sncb",
"name": "SNCB" "name": "SNCB"
}, },
{ {
"file": "logo-sncf-color.svg", "file": "logo-sncf-color.svg",
"path": "/img/type/logo-sncf-color.svg", "path": "/logo-sncf-color.svg",
"slug": "sncf", "slug": "sncf",
"name": "Sncf" "name": "Sncf"
}, },
@@ -255,127 +255,127 @@
}, },
{ {
"file": "sncf-voyageurs-logo.png", "file": "sncf-voyageurs-logo.png",
"path": "/img/type/sncf-voyageurs-logo.png", "path": "/sncf-voyageurs-logo.png",
"slug": "ter", "slug": "ter",
"name": "TER" "name": "TER"
}, },
{ {
"file": "logo-ter-rhone-alpes.svg", "file": "logo-ter-rhone-alpes.svg",
"path": "/img/type/logo-ter-rhone-alpes.svg", "path": "/logo-ter-rhone-alpes.svg",
"slug": "ter-rhone-alpes", "slug": "ter-rhone-alpes",
"name": "TER Rhone Alpes" "name": "TER Rhone Alpes"
}, },
{ {
"file": "logo-teralsace.svg", "file": "logo-teralsace.svg",
"path": "/img/type/logo-teralsace.svg", "path": "/logo-teralsace.svg",
"slug": "teralsace", "slug": "teralsace",
"name": "Teralsace" "name": "Teralsace"
}, },
{ {
"file": "logo-teraquitaine.svg", "file": "logo-teraquitaine.svg",
"path": "/img/type/logo-teraquitaine.svg", "path": "/logo-teraquitaine.svg",
"slug": "teraquitaine", "slug": "teraquitaine",
"name": "Teraquitaine" "name": "Teraquitaine"
}, },
{ {
"file": "logo-terbassenormandie.svg", "file": "logo-terbassenormandie.svg",
"path": "/img/type/logo-terbassenormandie.svg", "path": "/logo-terbassenormandie.svg",
"slug": "terbassenormandie", "slug": "terbassenormandie",
"name": "Terbassenormandie" "name": "Terbassenormandie"
}, },
{ {
"file": "logo-terbourgogne.svg", "file": "logo-terbourgogne.svg",
"path": "/img/type/logo-terbourgogne.svg", "path": "/logo-terbourgogne.svg",
"slug": "terbourgogne", "slug": "terbourgogne",
"name": "Terbourgogne" "name": "Terbourgogne"
}, },
{ {
"file": "logo-terbretagne.svg", "file": "logo-terbretagne.svg",
"path": "/img/type/logo-terbretagne.svg", "path": "/logo-terbretagne.svg",
"slug": "terbretagne", "slug": "terbretagne",
"name": "Terbretagne" "name": "Terbretagne"
}, },
{ {
"file": "logo-tercentre.svg", "file": "logo-tercentre.svg",
"path": "/img/type/logo-tercentre.svg", "path": "/logo-tercentre.svg",
"slug": "tercentre", "slug": "tercentre",
"name": "Tercentre" "name": "Tercentre"
}, },
{ {
"file": "logo-terlanguedocroussillon.svg", "file": "logo-terlanguedocroussillon.svg",
"path": "/img/type/logo-terlanguedocroussillon.svg", "path": "/logo-terlanguedocroussillon.svg",
"slug": "terlanguedocroussillon", "slug": "terlanguedocroussillon",
"name": "Terlanguedocroussillon" "name": "Terlanguedocroussillon"
}, },
{ {
"file": "logo-termidipyrenees.svg", "file": "logo-termidipyrenees.svg",
"path": "/img/type/logo-termidipyrenees.svg", "path": "/logo-termidipyrenees.svg",
"slug": "termidipyrenees", "slug": "termidipyrenees",
"name": "Termidipyrenees" "name": "Termidipyrenees"
}, },
{ {
"file": "logo-ternordpasdecalais.svg", "file": "logo-ternordpasdecalais.svg",
"path": "/img/type/logo-ternordpasdecalais.svg", "path": "/logo-ternordpasdecalais.svg",
"slug": "ternordpasdecalais", "slug": "ternordpasdecalais",
"name": "Ternordpasdecalais" "name": "Ternordpasdecalais"
}, },
{ {
"file": "logo-terpoitoucharentes.svg", "file": "logo-terpoitoucharentes.svg",
"path": "/img/type/logo-terpoitoucharentes.svg", "path": "/logo-terpoitoucharentes.svg",
"slug": "terpoitoucharentes", "slug": "terpoitoucharentes",
"name": "Terpoitoucharentes" "name": "Terpoitoucharentes"
}, },
{ {
"file": "logo-tgv.svg", "file": "logo-tgv.svg",
"path": "/img/type/logo-tgv.svg", "path": "/logo-tgv.svg",
"slug": "tgv", "slug": "tgv",
"name": "TGV" "name": "TGV"
}, },
{ {
"file": "logo-inoui.svg", "file": "logo-inoui.svg",
"path": "/img/type/logo-inoui.svg", "path": "/logo-inoui.svg",
"slug": "inoui", "slug": "inoui",
"name": "TGV InOui" "name": "TGV InOui"
}, },
{ {
"file": "logo-lyria.svg", "file": "logo-lyria.svg",
"path": "/img/type/logo-lyria.svg", "path": "/logo-lyria.svg",
"slug": "lyria", "slug": "lyria",
"name": "TGV Lyria" "name": "TGV Lyria"
}, },
{ {
"file": "logo-thalys.svg", "file": "logo-thalys.svg",
"path": "/img/type/logo-thalys.svg", "path": "/logo-thalys.svg",
"slug": "thalys", "slug": "thalys",
"name": "Thalys" "name": "Thalys"
}, },
{ {
"file": "logo-thello.svg", "file": "logo-thello.svg",
"path": "/img/type/logo-thello.svg", "path": "/logo-thello.svg",
"slug": "thello", "slug": "thello",
"name": "Thello" "name": "Thello"
}, },
{ {
"file": "track-car.svg", "file": "track-car.svg",
"path": "/img/type/track-car.svg", "path": "/track-car.svg",
"slug": "track-car", "slug": "track-car",
"name": "Track Car" "name": "Track Car"
}, },
{ {
"file": "logo-tramtrain.svg", "file": "logo-tramtrain.svg",
"path": "/img/type/logo-tramtrain.svg", "path": "/logo-tramtrain.svg",
"slug": "tramtrain", "slug": "tramtrain",
"name": "Tramtrain" "name": "Tramtrain"
}, },
{ {
"file": "logo-trenitalia.svg", "file": "logo-trenitalia.svg",
"path": "/img/type/logo-trenitalia.svg", "path": "/logo-trenitalia.svg",
"slug": "trenitalia", "slug": "trenitalia",
"name": "Trenitalia" "name": "Trenitalia"
}, },
{ {
"file": "logo-zou.svg", "file": "logo-zou.svg",
"path": "/img/type/logo-zou.svg", "path": "/logo-zou.svg",
"slug": "zou", "slug": "zou",
"name": "Zou" "name": "Zou"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

-19
View File
@@ -1,19 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL;
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
async function run() {
const { data: gares, error: err1 } = await supabase.from('gares').select('*').ilike('nom', '%Ouges%').limit(1);
console.log("Gares:", gares);
if (err1) console.error(err1);
const { data: horaires, error: err2 } = await supabase.from('horaires').select('*').limit(5);
// find one that stops at Ouges
const { data: horairesOuges, error: err3 } = await supabase.from('horaires').select('*').ilike('gares_desservies', '%Ouges%').limit(2);
console.log("Horaires Ouges:", JSON.stringify(horairesOuges, null, 2));
}
run();
-11
View File
@@ -1,11 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies, quai_reel').or('gare_depart_nom.ilike.%Ouges%,gare_arrivee_nom.ilike.%Ouges%').limit(5);
console.log("Horaires Ouges:", JSON.stringify(data, null, 2));
}
run();
-13
View File
@@ -1,13 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies, quai_reel').limit(10);
// find any train with Ouges
const filtered = data.filter(h => JSON.stringify(h).includes("Ouges"));
console.log("Horaires Ouges filtered:", JSON.stringify(filtered, null, 2));
}
run();
-12
View File
@@ -1,12 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('*').limit(1);
console.log("Horaires schema:", Object.keys(data[0] || {}));
if (error) console.error("Error:", error);
}
run();
-19
View File
@@ -1,19 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies').or('gare_depart_nom.ilike.%Ouges%,gare_arrivee_nom.ilike.%Ouges%');
if (!data || data.length === 0) {
// try to fetch all and filter in JS
const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies').limit(200);
const filtered = allData.filter(h => JSON.stringify(h).includes("Ouges"));
console.log("Horaires Ouges (JS filter):", JSON.stringify(filtered.slice(0, 2), null, 2));
} else {
console.log("Horaires Ouges:", JSON.stringify(data.slice(0, 2), null, 2));
}
}
run();
-9
View File
@@ -1,9 +0,0 @@
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('annonces_diffusions').select('*').order('created_at', { ascending: false }).limit(1);
if (error) console.error(error);
else console.log(JSON.stringify(data[0], null, 2));
}
run();
-9
View File
@@ -1,9 +0,0 @@
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('annonces').select('*').limit(2);
if (error) console.error(error);
else console.log(JSON.stringify(data, null, 2));
}
run();
-9
View File
@@ -1,9 +0,0 @@
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('annonces_diffusions').select('*').limit(1);
if (error) console.error(error);
else console.log(Object.keys(data[0]));
}
run();
-9
View File
@@ -1,9 +0,0 @@
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('horaires').select('*').limit(1);
if (error) console.error(error);
else console.log(JSON.stringify(data[0], null, 2));
}
run();
-12
View File
@@ -1,12 +0,0 @@
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data, error } = await supabase.from('annonces_diffusions').select(`
*,
annonce:annonces_enregistrees(variables_values)
`).order('created_at', { ascending: false }).limit(1);
if (error) console.error(error);
else console.log(JSON.stringify(data[0], null, 2));
}
run();
+204
View File
@@ -0,0 +1,204 @@
const fs = require('fs');
const filePath = 'c:\\Users\\mathi\\Documents\\Développement WEB\\ferrovia-infogare\\src\\App.js';
let content = fs.readFileSync(filePath, 'utf-8');
content = content.replace(
"import React, { useEffect, useState } from 'react';",
"import React, { useEffect, useState } from 'react';\nimport { WcsCard, WcsCardBody, WcsButton, WcsBadge } from 'wcs-react';"
);
const home_component_old = ` return (
<div className="launcher-container">
{/* Carte de bienvenue */}
<div className="launcher-card">
<div className="launcher-header">
<div>
<h1 className="launcher-title">🚅 Téléaffichage Ferroviaire SNCF</h1>
<p className="launcher-subtitle">
{source === 'supabase'
? "Consultez les afficheurs basés sur les données de votre BDD Supabase."
: "Simulateur d'écrans de gare officiels connectés en direct aux flux GTFS & Open Data SNCF."}
</p>
</div>
<span className={\`launcher-badge \${source === 'supabase' ? 'launcher-badge--supabase' : ''}\`}>
{source === 'supabase' ? '🗄️ Mode Supabase' : '🌐 Mode SNCF Live'}
</span>
</div>
{/* Cartes principales : Départs / Arrivées */}
<div className="launcher-grid launcher-grid--2col">
<div
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(\`\${prefix}/departs\`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(\`\${prefix}/departs\`);
}
}}
>
<div>
<span className="launcher-card-icon">🚆</span>
<span className="launcher-card-tag launcher-card-tag--modern">Départs</span>
<h2 className="launcher-card-title">Prochains Départs</h2>
<p className="launcher-card-desc">
Consultez les trains au départ, leurs voies, retards et dessertes complètes.
</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--primary">
Consulter les Départs ›
</span>
</div>
</div>
<div
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(\`\${prefix}/arrivees\`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(\`\${prefix}/arrivees\`);
}
}}
>
<div>
<span className="launcher-card-icon">🏁</span>
<span className="launcher-card-tag launcher-card-tag--modern">Arrivées</span>
<h2 className="launcher-card-title">Prochaines Arrivées</h2>
<p className="launcher-card-desc">
Suivez en direct les trains attendus en gare, provenances et voies assignées.
</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--secondary">
Consulter les Arrivées ›
</span>
</div>
</div>
</div>
{/* Raccourcis rapides vers les gares */}
<div className="launcher-chips-container" style={{ marginTop: '8px' }}>
<span className="launcher-chips-label">Accès direct par gare :</span>
<div className="launcher-chips-list">
{POPULAR_HOME_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(\`\${prefix}/afficheurs/\${encodeURIComponent(gName)}?mode=departs\`);
}}
>
📍 {gName.replace(/-/g, ' ')}
</button>
))}
</div>
</div>
{/* Présentation des types d'afficheurs */}
<div style={{ borderTop: '1px solid #e2ebf6', paddingTop: '20px', marginTop: '20px' }}>
<div style={{ fontSize: '13px', color: '#5f6368', lineHeight: 1.6 }}>
✨ <strong>Formats disponibles :</strong> Afficheurs Fluides & Lumineux (AFL), Téléaffichage historique (Classiques), Écrans Vidéo Voyageurs (EVA) et Écrans par Quai / Voie.
</div>
</div>
</div>
</div>
);`;
const home_component_new = ` return (
<div className="launcher-container" style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
<WcsCard mode="raised">
<WcsCardBody>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div>
<h1 style={{ margin: '0 0 8px 0', fontSize: '24px', color: 'var(--wcs-primary)' }}>🚅 Téléaffichage Ferroviaire SNCF</h1>
<p style={{ margin: 0, color: 'var(--wcs-text-light)' }}>
{source === 'supabase'
? "Consultez les afficheurs basés sur les données de votre BDD Supabase."
: "Simulateur d'écrans de gare officiels connectés en direct aux flux GTFS & Open Data SNCF."}
</p>
</div>
<WcsBadge className={source === 'supabase' ? 'wcs-secondary' : 'wcs-primary'} shape="rounded">
{source === 'supabase' ? '🗄️ Mode Supabase' : '🌐 Mode SNCF Live'}
</WcsBadge>
</div>
<div style={{ display: 'flex', gap: '24px', flexDirection: 'row', flexWrap: 'wrap', marginBottom: '24px' }}>
<WcsCard style={{ flex: '1 1 300px', cursor: 'pointer' }} mode="flat" onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(\`\${prefix}/departs\`);
}}>
<WcsCardBody>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '32px' }}>🚆</span>
<WcsBadge className="wcs-success">Départs</WcsBadge>
</div>
<h2 style={{ fontSize: '18px', marginTop: '16px' }}>Prochains Départs</h2>
<p style={{ color: 'var(--wcs-text-light)', minHeight: '60px' }}>Consultez les trains au départ, leurs voies, retards et dessertes complètes.</p>
<WcsButton className="wcs-primary" style={{ width: '100%' }}>Consulter les Départs</WcsButton>
</WcsCardBody>
</WcsCard>
<WcsCard style={{ flex: '1 1 300px', cursor: 'pointer' }} mode="flat" onClick={() => {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(\`\${prefix}/arrivees\`);
}}>
<WcsCardBody>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '32px' }}>🏁</span>
<WcsBadge className="wcs-info">Arrivées</WcsBadge>
</div>
<h2 style={{ fontSize: '18px', marginTop: '16px' }}>Prochaines Arrivées</h2>
<p style={{ color: 'var(--wcs-text-light)', minHeight: '60px' }}>Suivez en direct les trains attendus en gare, provenances et voies assignées.</p>
<WcsButton className="wcs-secondary" style={{ width: '100%' }}>Consulter les Arrivées</WcsButton>
</WcsCardBody>
</WcsCard>
</div>
<div style={{ marginBottom: '24px' }}>
<p style={{ fontWeight: 'bold', marginBottom: '12px' }}>Accès direct par gare :</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{POPULAR_HOME_GARES.map((gName) => (
<WcsButton
key={gName}
mode="stroked"
shape="round"
size="s"
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(\`\${prefix}/afficheurs/\${encodeURIComponent(gName)}?mode=departs\`);
}}
>
📍 {gName.replace(/-/g, ' ')}
</WcsButton>
))}
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--wcs-light)', margin: '24px 0' }} />
<div style={{ fontSize: '14px', color: 'var(--wcs-text-light)', lineHeight: 1.6 }}>
✨ <strong>Formats disponibles :</strong> Afficheurs Fluides & Lumineux (AFL), Téléaffichage historique (Classiques), Écrans Vidéo Voyageurs (EVA) et Écrans par Quai / Voie.
</div>
</WcsCardBody>
</WcsCard>
</div>
);`;
content = content.replace(home_component_old, home_component_new);
fs.writeFileSync(filePath, content, 'utf-8');
console.log('App.js patched successfully');
+207
View File
@@ -0,0 +1,207 @@
import os
file_path = r"c:\Users\mathi\Documents\Développement WEB\ferrovia-infogare\src\App.js"
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Make the changes in App.js for the Home component
new_content = content.replace(
"import React, { useEffect, useState } from 'react';",
"import React, { useEffect, useState } from 'react';\nimport { WcsCard, WcsCardBody, WcsButton, WcsBadge } from 'wcs-react';"
)
home_component_old = """ return (
<div className="launcher-container">
{/* Carte de bienvenue */}
<div className="launcher-card">
<div className="launcher-header">
<div>
<h1 className="launcher-title">🚅 Téléaffichage Ferroviaire SNCF</h1>
<p className="launcher-subtitle">
{source === 'supabase'
? "Consultez les afficheurs basés sur les données de votre BDD Supabase."
: "Simulateur d'écrans de gare officiels connectés en direct aux flux GTFS & Open Data SNCF."}
</p>
</div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ Mode Supabase' : '🌐 Mode SNCF Live'}
</span>
</div>
{/* Cartes principales : Départs / Arrivées */}
<div className="launcher-grid launcher-grid--2col">
<div
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/departs`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/departs`);
}
}}
>
<div>
<span className="launcher-card-icon">🚆</span>
<span className="launcher-card-tag launcher-card-tag--modern">Départs</span>
<h2 className="launcher-card-title">Prochains Départs</h2>
<p className="launcher-card-desc">
Consultez les trains au départ, leurs voies, retards et dessertes complètes.
</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--primary">
Consulter les Départs ›
</span>
</div>
</div>
<div
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(`${prefix}/arrivees`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(`${prefix}/arrivees`);
}
}}
>
<div>
<span className="launcher-card-icon">🏁</span>
<span className="launcher-card-tag launcher-card-tag--modern">Arrivées</span>
<h2 className="launcher-card-title">Prochaines Arrivées</h2>
<p className="launcher-card-desc">
Suivez en direct les trains attendus en gare, provenances et voies assignées.
</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--secondary">
Consulter les Arrivées ›
</span>
</div>
</div>
</div>
{/* Raccourcis rapides vers les gares */}
<div className="launcher-chips-container" style={{ marginTop: '8px' }}>
<span className="launcher-chips-label">Accès direct par gare :</span>
<div className="launcher-chips-list">
{POPULAR_HOME_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/afficheurs/${encodeURIComponent(gName)}?mode=departs`);
}}
>
📍 {gName.replace(/-/g, ' ')}
</button>
))}
</div>
</div>
{/* Présentation des types d'afficheurs */}
<div style={{ borderTop: '1px solid #e2ebf6', paddingTop: '20px', marginTop: '20px' }}>
<div style={{ fontSize: '13px', color: '#5f6368', lineHeight: 1.6 }}>
✨ <strong>Formats disponibles :</strong> Afficheurs Fluides & Lumineux (AFL), Téléaffichage historique (Classiques), Écrans Vidéo Voyageurs (EVA) et Écrans par Quai / Voie.
</div>
</div>
</div>
</div>
);"""
home_component_new = """ return (
<div className="launcher-container" style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
<WcsCard mode="raised">
<WcsCardBody>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div>
<h1 style={{ margin: '0 0 8px 0', fontSize: '24px', color: 'var(--wcs-primary)' }}>🚅 Téléaffichage Ferroviaire SNCF</h1>
<p style={{ margin: 0, color: 'var(--wcs-text-light)' }}>
{source === 'supabase'
? "Consultez les afficheurs basés sur les données de votre BDD Supabase."
: "Simulateur d'écrans de gare officiels connectés en direct aux flux GTFS & Open Data SNCF."}
</p>
</div>
<WcsBadge className={source === 'supabase' ? 'wcs-secondary' : 'wcs-primary'} shape="rounded">
{source === 'supabase' ? '🗄️ Mode Supabase' : '🌐 Mode SNCF Live'}
</WcsBadge>
</div>
<div style={{ display: 'flex', gap: '24px', flexDirection: 'row', flexWrap: 'wrap', marginBottom: '24px' }}>
<WcsCard style={{ flex: '1 1 300px', cursor: 'pointer' }} mode="flat" onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/departs`);
}}>
<WcsCardBody>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '32px' }}>🚆</span>
<WcsBadge className="wcs-success">Départs</WcsBadge>
</div>
<h2 style={{ fontSize: '18px', marginTop: '16px' }}>Prochains Départs</h2>
<p style={{ color: 'var(--wcs-text-light)', minHeight: '60px' }}>Consultez les trains au départ, leurs voies, retards et dessertes complètes.</p>
<WcsButton className="wcs-primary" style={{ width: '100%' }}>Consulter les Départs</WcsButton>
</WcsCardBody>
</WcsCard>
<WcsCard style={{ flex: '1 1 300px', cursor: 'pointer' }} mode="flat" onClick={() => {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(`${prefix}/arrivees`);
}}>
<WcsCardBody>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '32px' }}>🏁</span>
<WcsBadge className="wcs-info">Arrivées</WcsBadge>
</div>
<h2 style={{ fontSize: '18px', marginTop: '16px' }}>Prochaines Arrivées</h2>
<p style={{ color: 'var(--wcs-text-light)', minHeight: '60px' }}>Suivez en direct les trains attendus en gare, provenances et voies assignées.</p>
<WcsButton className="wcs-secondary" style={{ width: '100%' }}>Consulter les Arrivées</WcsButton>
</WcsCardBody>
</WcsCard>
</div>
<div style={{ marginBottom: '24px' }}>
<p style={{ fontWeight: 'bold', marginBottom: '12px' }}>Accès direct par gare :</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{POPULAR_HOME_GARES.map((gName) => (
<WcsButton
key={gName}
mode="stroked"
shape="round"
size="s"
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/afficheurs/${encodeURIComponent(gName)}?mode=departs`);
}}
>
📍 {gName.replace(/-/g, ' ')}
</WcsButton>
))}
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--wcs-light)', margin: '24px 0' }} />
<div style={{ fontSize: '14px', color: 'var(--wcs-text-light)', lineHeight: 1.6 }}>
✨ <strong>Formats disponibles :</strong> Afficheurs Fluides & Lumineux (AFL), Téléaffichage historique (Classiques), Écrans Vidéo Voyageurs (EVA) et Écrans par Quai / Voie.
</div>
</WcsCardBody>
</WcsCard>
</div>
);"""
new_content = new_content.replace(home_component_old, home_component_new)
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_content)
+25
View File
@@ -11,6 +11,31 @@
/* Styles responsive pour les pages de guidage & sélection */ /* Styles responsive pour les pages de guidage & sélection */
@import "./styles/Launcher.css"; @import "./styles/Launcher.css";
@font-face {
font-family: 'Achemine';
src: url('./fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('./fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('./fonts/achemine_italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face { @font-face {
font-family: Avenir; font-family: Avenir;
src: url("/public/fonts/avenir-lighter.woff"); src: url("/public/fonts/avenir-lighter.woff");
+82 -99
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { WcsCard, WcsCardBody, WcsButton, WcsBadge } from 'wcs-react';
import './App.css'; import './App.css';
import { applyPolyfills, defineCustomElements } from 'wcs-core/loader'; import { applyPolyfills, defineCustomElements } from 'wcs-core/loader';
@@ -9,6 +10,8 @@ import ClassiquesDeparts from './pages/afficheurs/classiques/ClassiquesDeparts';
import ClassiquesArrivees from './pages/afficheurs/classiques/ClassiquesArrivees'; import ClassiquesArrivees from './pages/afficheurs/classiques/ClassiquesArrivees';
import ClassiquesDepartsQuais from './pages/afficheurs/classiques/quais/ClassiquesDepartsQuais'; import ClassiquesDepartsQuais from './pages/afficheurs/classiques/quais/ClassiquesDepartsQuais';
import ClassiquesArriveesQuais from './pages/afficheurs/classiques/quais/ClassiquesArriveesQuais'; import ClassiquesArriveesQuais from './pages/afficheurs/classiques/quais/ClassiquesArriveesQuais';
import ClassiquesFlashInfo from './pages/afficheurs/classiques/ClassiquesFlashInfo';
import ClassiquesInformations from './pages/afficheurs/classiques/ClassiquesInformations';
import AFLDeparts from './pages/afficheurs/afl/AFLDeparts'; import AFLDeparts from './pages/afficheurs/afl/AFLDeparts';
import AFLArrivees from './pages/afficheurs/afl/AFLArrivees'; import AFLArrivees from './pages/afficheurs/afl/AFLArrivees';
import AFLDepartsQuais from './pages/afficheurs/afl/AFLDepartsQuais'; import AFLDepartsQuais from './pages/afficheurs/afl/AFLDepartsQuais';
@@ -22,11 +25,9 @@ import { getSourceFromPath } from './services/railwayService';
applyPolyfills().then(() => defineCustomElements(window)); applyPolyfills().then(() => defineCustomElements(window));
// Petit routeur client sans dépendances externes
function useSimpleRouter() { function useSimpleRouter() {
function normalizePath(p) { function normalizePath(p) {
if (!p) return '/'; if (!p) return '/';
// retirer query string et hash
p = String(p).split(/[?#]/)[0]; p = String(p).split(/[?#]/)[0];
if (p.indexOf('index.html') !== -1) return '/'; if (p.indexOf('index.html') !== -1) return '/';
p = p.trim(); p = p.trim();
@@ -72,7 +73,6 @@ function App() {
const prefix = source === 'supabase' ? '/ferrovia' : ''; const prefix = source === 'supabase' ? '/ferrovia' : '';
const cleanRoute = (route.replace(/^\/ferrovia/, '') || '/'); const cleanRoute = (route.replace(/^\/ferrovia/, '') || '/');
// Composant Home moderne & responsive pour Mobile, Tablette et Desktop
function Home({ navigate, prefix, source }) { function Home({ navigate, prefix, source }) {
const POPULAR_HOME_GARES = [ const POPULAR_HOME_GARES = [
'Paris-Gare-de-Lyon', 'Paris-Gare-de-Lyon',
@@ -86,118 +86,101 @@ function App() {
]; ];
return ( return (
<div className="launcher-container"> <div className="launcher-container" style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
{/* Carte de bienvenue */} <WcsCard mode="raised">
<div className="launcher-card"> <WcsCardBody>
<div className="launcher-header"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div> <div>
<h1 className="launcher-title">🚅 Téléaffichage Ferroviaire SNCF</h1> <h1 style={{ margin: '0 0 8px 0', fontSize: '24px', color: 'var(--wcs-primary)' }}>🚅 Téléaffichage Ferroviaire SNCF</h1>
<p className="launcher-subtitle"> <p style={{ margin: 0, color: 'var(--wcs-text-light)' }}>
{source === 'supabase' {source === 'supabase'
? "Consultez les afficheurs basés sur les données de votre BDD Supabase." ? "Consultez les afficheurs basés sur les données de votre BDD Supabase."
: "Simulateur d'écrans de gare officiels connectés en direct aux flux GTFS & Open Data SNCF."} : "Simulateur d'écrans de gare officiels connectés en direct aux flux GTFS & Open Data SNCF."}
</p> </p>
</div>
<WcsBadge className={source === 'supabase' ? 'wcs-secondary' : 'wcs-primary'} shape="rounded">
{source === 'supabase' ? '🗄️ Mode Supabase' : '🌐 Mode SNCF Live'}
</WcsBadge>
</div> </div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ Mode Supabase' : '🌐 Mode SNCF Live'}
</span>
</div>
{/* Cartes principales : Départs / Arrivées */} <div style={{ display: 'flex', gap: '24px', flexDirection: 'row', flexWrap: 'wrap', marginBottom: '24px' }}>
<div className="launcher-grid launcher-grid--2col"> <WcsCard style={{ flex: '1 1 300px', cursor: 'pointer', border: '1px solid var(--wcs-light)' }} mode="flat" onClick={() => {
<div
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/departs`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {} try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/departs`); navigate(`${prefix}/departs`);
} }}>
}} <WcsCardBody>
> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <span style={{ fontSize: '32px' }}>🚆</span>
<span className="launcher-card-icon">🚆</span> <WcsBadge className="wcs-success">Départs</WcsBadge>
<span className="launcher-card-tag launcher-card-tag--modern">Départs</span> </div>
<h2 className="launcher-card-title">Prochains Départs</h2> <h2 style={{ fontSize: '18px', marginTop: '16px' }}>Prochains Départs</h2>
<p className="launcher-card-desc"> <p style={{ color: 'var(--wcs-text-light)', minHeight: '60px' }}>Consultez les trains au départ, leurs voies, retards et dessertes complètes.</p>
Consultez les trains au départ, leurs voies, retards et dessertes complètes. <WcsButton className="wcs-primary" style={{ width: '100%' }}>Consulter les Départs</WcsButton>
</p> </WcsCardBody>
</div> </WcsCard>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--primary">
Consulter les Départs ›
</span>
</div>
</div>
<div <WcsCard style={{ flex: '1 1 300px', cursor: 'pointer', border: '1px solid var(--wcs-light)' }} mode="flat" onClick={() => {
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(`${prefix}/arrivees`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {} try { sessionMemory.set('afficheurMode', 'arrivees'); } catch (e) {}
navigate(`${prefix}/arrivees`); navigate(`${prefix}/arrivees`);
} }}>
}} <WcsCardBody>
> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <span style={{ fontSize: '32px' }}>🏁</span>
<span className="launcher-card-icon">🏁</span> <WcsBadge className="wcs-info">Arrivées</WcsBadge>
<span className="launcher-card-tag launcher-card-tag--modern">Arrivées</span> </div>
<h2 className="launcher-card-title">Prochaines Arrivées</h2> <h2 style={{ fontSize: '18px', marginTop: '16px' }}>Prochaines Arrivées</h2>
<p className="launcher-card-desc"> <p style={{ color: 'var(--wcs-text-light)', minHeight: '60px' }}>Suivez en direct les trains attendus en gare, provenances et voies assignées.</p>
Suivez en direct les trains attendus en gare, provenances et voies assignées. <WcsButton className="wcs-secondary" style={{ width: '100%' }}>Consulter les Arrivées</WcsButton>
</p> </WcsCardBody>
</div> </WcsCard>
<div style={{ marginTop: 12 }}> </div>
<span className="launcher-btn launcher-btn--secondary">
Consulter les Arrivées › <div style={{ marginBottom: '24px' }}>
</span> <p style={{ fontWeight: 'bold', marginBottom: '12px' }}>Accès direct par gare :</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{POPULAR_HOME_GARES.map((gName) => (
<WcsButton
key={gName}
mode="stroked"
shape="round"
size="s"
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/afficheurs/${encodeURIComponent(gName)}?mode=departs`);
}}
>
📍 {gName.replace(/-/g, ' ')}
</WcsButton>
))}
</div> </div>
</div> </div>
</div>
{/* Raccourcis rapides vers les gares */} <hr style={{ border: 'none', borderTop: '1px solid var(--wcs-light)', margin: '24px 0' }} />
<div className="launcher-chips-container" style={{ marginTop: '8px' }}> <div style={{ fontSize: '14px', color: 'var(--wcs-text-light)', lineHeight: 1.6 }}>
<span className="launcher-chips-label">Accès direct par gare :</span>
<div className="launcher-chips-list">
{POPULAR_HOME_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => {
try { sessionMemory.set('afficheurMode', 'departs'); } catch (e) {}
navigate(`${prefix}/afficheurs/${encodeURIComponent(gName)}?mode=departs`);
}}
>
📍 {gName.replace(/-/g, ' ')}
</button>
))}
</div>
</div>
{/* Présentation des types d'afficheurs */}
<div style={{ borderTop: '1px solid #e2ebf6', paddingTop: '20px', marginTop: '20px' }}>
<div style={{ fontSize: '13px', color: '#5f6368', lineHeight: 1.6 }}>
✨ <strong>Formats disponibles :</strong> Afficheurs Fluides & Lumineux (AFL), Téléaffichage historique (Classiques), Écrans Vidéo Voyageurs (EVA) et Écrans par Quai / Voie. ✨ <strong>Formats disponibles :</strong> Afficheurs Fluides & Lumineux (AFL), Téléaffichage historique (Classiques), Écrans Vidéo Voyageurs (EVA) et Écrans par Quai / Voie.
</div> </div>
</div> </WcsCardBody>
</div> </WcsCard>
</div> </div>
); );
} }
// Routes pour afficheurs : supporte le mode racine ET le mode /ferrovia if (
cleanRoute.startsWith('/afficheurs/classiques/flash-info') ||
cleanRoute.startsWith('/afficheurs/classiques/flash-circulation') ||
cleanRoute.startsWith('/afficheurs/departs/classiques/flash-info') ||
cleanRoute.startsWith('/afficheurs/arrivees/classiques/flash-info')
) {
return <AfficheurViewportWrapper><ClassiquesFlashInfo /></AfficheurViewportWrapper>;
}
if (
cleanRoute.startsWith('/afficheurs/classiques/informations') ||
cleanRoute.startsWith('/afficheurs/classiques/infos') ||
cleanRoute.startsWith('/afficheurs/departs/classiques/informations') ||
cleanRoute.startsWith('/afficheurs/arrivees/classiques/informations')
) {
return <AfficheurViewportWrapper><ClassiquesInformations /></AfficheurViewportWrapper>;
}
if (cleanRoute.startsWith('/afficheurs/departs/classiques/quais')) { if (cleanRoute.startsWith('/afficheurs/departs/classiques/quais')) {
return <AfficheurViewportWrapper><ClassiquesDepartsQuais /></AfficheurViewportWrapper>; return <AfficheurViewportWrapper><ClassiquesDepartsQuais /></AfficheurViewportWrapper>;
} }
+141 -21
View File
@@ -1,23 +1,24 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import './AfficheurViewportWrapper.css'; import './AfficheurViewportWrapper.css';
import { createPortal } from 'react-dom';
/**
* AfficheurViewportWrapper
* Assure un affichage 16:9 strict (1920x1080) sur tous les appareils
* (Mobile portrait, Mobile paysage, Tablettes, Ordinateurs, TV).
* Ajoute automatiquement des bordures noires (letterboxing / pillarboxing)
* pour conserver fidèlement les proportions du design original sans distorsion.
*/
export default function AfficheurViewportWrapper({ children }) { export default function AfficheurViewportWrapper({ children }) {
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const [pipWindow, setPipWindow] = useState(null);
const containerRef = useRef(null);
const [isPipSupported, setIsPipSupported] = useState(false);
const [autoPipTriggered, setAutoPipTriggered] = useState(false);
const isMobile = typeof window !== 'undefined' && (window.innerWidth <= 768 || /Mobi|Android/i.test(navigator.userAgent));
useEffect(() => { useEffect(() => {
if ('documentPictureInPicture' in window) {
setIsPipSupported(true);
}
const calculateScale = () => { const calculateScale = () => {
// Dimensions de la fenêtre d'affichage (support viewport iOS / Android safe area)
const w = window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 1920; const w = window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 1920;
const h = window.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 1080; const h = window.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 1080;
// Échelle basée sur la résolution standard 1920x1080 (16:9)
const scaleX = w / 1920; const scaleX = w / 1920;
const scaleY = h / 1080; const scaleY = h / 1080;
const s = Math.min(scaleX, scaleY); const s = Math.min(scaleX, scaleY);
@@ -27,7 +28,6 @@ export default function AfficheurViewportWrapper({ children }) {
calculateScale(); calculateScale();
window.addEventListener('resize', calculateScale); window.addEventListener('resize', calculateScale);
window.addEventListener('orientationchange', calculateScale); window.addEventListener('orientationchange', calculateScale);
if (window.visualViewport) { if (window.visualViewport) {
window.visualViewport.addEventListener('resize', calculateScale); window.visualViewport.addEventListener('resize', calculateScale);
} }
@@ -41,16 +41,136 @@ export default function AfficheurViewportWrapper({ children }) {
}; };
}, []); }, []);
const togglePiP = async () => {
if (pipWindow) {
pipWindow.close();
return;
}
if ('documentPictureInPicture' in window) {
try {
const pipW = isMobile ? window.innerWidth * 0.8 : 1920 / 3;
const pipH = isMobile ? (window.innerWidth * 0.8) * (1080 / 1920) : 1080 / 3;
const pip = await window.documentPictureInPicture.requestWindow({
width: Math.max(pipW, 300),
height: Math.max(pipH, 170),
});
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map(rule => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pip.document.head.appendChild(style);
} catch(e) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href;
pip.document.head.appendChild(link);
}
});
pip.document.body.classList.add('pip-mode');
pip.document.body.style.margin = '0';
pip.document.body.style.padding = '0';
pip.document.body.style.overflow = 'hidden';
pip.document.body.style.width = '100vw';
pip.document.body.style.height = '100vh';
pip.document.body.style.backgroundColor = '#000';
pip.document.body.style.display = 'flex';
pip.document.body.style.justifyContent = 'center';
pip.document.body.style.alignItems = 'center';
const updatePipScale = () => {
const w = pip.innerWidth;
const h = pip.innerHeight;
const pipScale = Math.min(w / 1920, h / 1080);
pip.document.body.style.setProperty('--pip-scale', pipScale);
};
updatePipScale();
pip.addEventListener('resize', updatePipScale);
pip.addEventListener("pagehide", () => {
setPipWindow(null);
});
setPipWindow(pip);
setAutoPipTriggered(true);
} catch (err) {
console.error('Failed to enter PiP mode', err);
}
}
};
const content = (
<div
className="afficheur-pwa-canvas"
style={{
transform: pipWindow ? 'scale(1)' : `scale(${scale})`,
width: '1920px',
height: '1080px',
position: 'relative'
}}
>
{children}
</div>
);
return ( return (
<div className="afficheur-pwa-screen-wrapper"> <div className="afficheur-pwa-screen-wrapper" ref={containerRef} style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
<div {!pipWindow && content}
className="afficheur-pwa-canvas"
style={{ {isMobile && isPipSupported && !pipWindow && !autoPipTriggered && (
transform: `scale(${scale})`, <div
}} onClick={togglePiP}
> style={{
{children} position: 'absolute',
</div> top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.85)',
zIndex: 10000,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
color: 'white',
cursor: 'pointer',
padding: '20px',
textAlign: 'center'
}}
>
<span style={{ fontSize: '48px', marginBottom: '16px' }}>📱</span>
<h2 style={{ margin: '0 0 16px 0' }}>Appuyez pour lancer l'afficheur</h2>
<p style={{ opacity: 0.8 }}>Le mode Picture-in-Picture s'activera pour une meilleure expérience sur mobile.</p>
</div>
)}
{pipWindow && (
<div style={{ color: 'white', textAlign: 'center', padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<h2>L'afficheur est en cours de lecture dans le lecteur réduit (PiP)</h2>
<p style={{ opacity: 0.7 }}>Vous pouvez naviguer sur votre téléphone, l'afficheur restera au premier plan.</p>
<button onClick={() => pipWindow.close()} style={{ padding: '12px 24px', marginTop: '24px', background: '#00439e', color: 'white', border: 'none', borderRadius: '4px', fontSize: '16px', fontWeight: 'bold' }}>
Fermer le mode PiP
</button>
</div>
)}
{pipWindow && createPortal(
<div style={{
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `scale(var(--pip-scale, 1))`
}}>
{content}
</div>,
pipWindow.document.body
)}
</div> </div>
); );
} }
+29 -8
View File
@@ -1,21 +1,42 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import './MarqueeText.css'; import './MarqueeText.css';
const MarqueeText = ({ text, className, style }) => { const DEFAULT_SPEED = 50; // pixels per second for uniform, calm scrolling
const MarqueeText = ({ text, className, style, speed = DEFAULT_SPEED, duration }) => {
const containerRef = useRef(null); const containerRef = useRef(null);
const textRef = useRef(null); const textRef = useRef(null);
const [isOverflowing, setIsOverflowing] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false);
const [computedDuration, setComputedDuration] = useState('20s');
useEffect(() => { useEffect(() => {
const checkOverflow = () => { const updateMarquee = () => {
if (containerRef.current && textRef.current) { if (containerRef.current && textRef.current) {
setIsOverflowing(textRef.current.scrollWidth > containerRef.current.clientWidth); const containerWidth = containerRef.current.clientWidth;
const textWidth = textRef.current.scrollWidth;
const overflowing = textWidth > containerWidth;
setIsOverflowing(overflowing);
if (overflowing) {
if (duration) {
setComputedDuration(typeof duration === 'number' ? `${duration}s` : duration);
} else {
const totalDistance = textWidth + containerWidth;
const seconds = totalDistance / speed;
setComputedDuration(`${seconds.toFixed(2)}s`);
}
}
} }
}; };
checkOverflow();
window.addEventListener('resize', checkOverflow); updateMarquee();
return () => window.removeEventListener('resize', checkOverflow); window.addEventListener('resize', updateMarquee);
}, [text]); const timer = setTimeout(updateMarquee, 100);
return () => {
window.removeEventListener('resize', updateMarquee);
clearTimeout(timer);
};
}, [text, speed, duration]);
return ( return (
<div className={`marquee-container ${className || ''}`} ref={containerRef} style={{ ...style, overflow: 'hidden', whiteSpace: 'nowrap', width: '100%', flex: '1 1 0' }}> <div className={`marquee-container ${className || ''}`} ref={containerRef} style={{ ...style, overflow: 'hidden', whiteSpace: 'nowrap', width: '100%', flex: '1 1 0' }}>
@@ -24,7 +45,7 @@ const MarqueeText = ({ text, className, style }) => {
ref={textRef} ref={textRef}
style={{ style={{
display: 'inline-block', display: 'inline-block',
animation: isOverflowing ? 'marquee-scroll 10s linear infinite' : 'none', animation: isOverflowing ? `marquee-scroll ${computedDuration} linear infinite` : 'none',
paddingLeft: isOverflowing ? '100%' : '0', paddingLeft: isOverflowing ? '100%' : '0',
fontSize: 'inherit', fontSize: 'inherit',
fontWeight: 'inherit', fontWeight: 'inherit',
@@ -0,0 +1,202 @@
.aff-attentifs__container {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
user-select: none;
font-family: 'Achemine', 'Arial', sans-serif;
overflow: hidden;
box-sizing: border-box;
}
/* COLONNE GAUCHE (36%) */
.aff-attentifs__left-col {
flex: 0 0 36%;
background-color: #313131;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 70px 50px;
box-sizing: border-box;
}
.aff-attentifs__title-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.aff-attentifs__title-word {
font-size: 78px;
font-weight: 900;
color: #fdb913;
letter-spacing: 2px;
line-height: 1.05;
text-transform: uppercase;
}
.aff-attentifs__vigipirate-wrap {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: auto;
}
.aff-attentifs__vigipirate-svg {
width: 270px;
height: auto;
filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.45));
}
/* COLONNE DROITE (64%) */
.aff-attentifs__right-col {
flex: 1;
background-color: #4a4a4a;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 60px 80px;
box-sizing: border-box;
gap: 40px;
}
.aff-attentifs__info-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
color: #ffffff;
}
.aff-attentifs__info-desc {
font-size: 27px;
font-weight: 500;
line-height: 1.4;
margin: 0 0 36px 0;
color: #f8f8f8;
}
.aff-attentifs__call-action {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 30px;
}
.aff-attentifs__call-text {
font-size: 44px;
font-weight: 900;
letter-spacing: 0.5px;
color: #ffffff;
}
.aff-attentifs__call-hours {
font-size: 30px;
font-weight: 600;
color: #eaeaea;
}
.aff-attentifs__call-footnote {
font-size: 19px;
font-weight: 400;
color: #d0d0d0;
}
/* SECTION BULLE D'ALERTE 3117 */
.aff-attentifs__bubble-section {
flex: 0 0 460px;
display: flex;
align-items: center;
justify-content: center;
}
.aff-attentifs__bubble-wrap {
position: relative;
display: flex;
align-items: center;
}
.aff-attentifs__bubble-card {
position: relative;
background-color: #ffffff;
border: 7px solid #e2001a;
border-radius: 36px;
padding: 30px 42px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
min-width: 360px;
}
.aff-attentifs__bubble-title {
font-size: 28px;
font-weight: 900;
color: #e2001a;
letter-spacing: 1px;
}
.aff-attentifs__bubble-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.aff-attentifs__bubble-line {
display: flex;
align-items: baseline;
gap: 14px;
}
.aff-attentifs__bubble-label {
font-size: 30px;
font-weight: 800;
color: #e2001a;
min-width: 70px;
}
.aff-attentifs__bubble-number {
font-size: 58px;
font-weight: 900;
color: #e2001a;
letter-spacing: -1px;
}
.aff-attentifs__bubble-tail {
position: absolute;
bottom: -32px;
left: 54px;
width: 0;
height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
border-top: 32px solid #e2001a;
}
.aff-attentifs__bubble-tail::after {
content: '';
position: absolute;
top: -38px;
left: -13px;
width: 0;
height: 0;
border-left: 13px solid transparent;
border-right: 13px solid transparent;
border-top: 24px solid #ffffff;
}
/* SMARTPHONE BADGE */
.aff-attentifs__phone-badge {
position: absolute;
top: -34px;
right: -36px;
z-index: 10;
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.4));
}
.aff-attentifs__phone-svg {
width: 86px;
height: auto;
}
@@ -0,0 +1,112 @@
import React from 'react';
import './AttentifsEnsemble.css';
export default function AttentifsEnsemble() {
return (
<div className="aff-attentifs__container">
{/* COLONNE GAUCHE : ANTHRACITE FONCÉ (ATTENTIFS ENSEMBLE + VIGIPIRATE) */}
<div className="aff-attentifs__left-col">
<div className="aff-attentifs__title-group">
<div className="aff-attentifs__title-word">ATTENTIFS</div>
<div className="aff-attentifs__title-word">ENSEMBLE</div>
</div>
{/* Insigne Vigipirate Officiel */}
<div className="aff-attentifs__vigipirate-wrap">
<svg
viewBox="0 0 280 240"
className="aff-attentifs__vigipirate-svg"
xmlns="http://www.w3.org/2000/svg"
>
{/* Triangle noir extérieur */}
<polygon points="140,12 268,228 12,228" fill="#1e1e1e" stroke="#1e1e1e" strokeWidth="8" strokeLinejoin="round" />
{/* Triangle rouge intérieur */}
<polygon points="140,42 238,206 42,206" fill="#e2001a" />
{/* Bandeau blanc en bas */}
<polygon points="56,182 224,182 238,206 42,206" fill="#ffffff" />
{/* Texte VIGIPIRATE */}
<text
x="140"
y="201"
fill="#1e1e1e"
fontSize="23"
fontWeight="900"
fontFamily="Achemine, Arial, sans-serif"
textAnchor="middle"
letterSpacing="2.5"
>
VIGIPIRATE
</text>
</svg>
</div>
</div>
{/* COLONNE DROITE : GRIS MOYEN (CONSIGNES + BULLE ALERTE 3117) */}
<div className="aff-attentifs__right-col">
{/* Partie gauche des consignes */}
<div className="aff-attentifs__info-section">
<p className="aff-attentifs__info-desc">
Si vous êtes témoin<br />
d'une situation<br />
qui présente un risque<br />
pour votre sécurité<br />
ou celle des autres<br />
voyageurs,
</p>
<div className="aff-attentifs__call-action">
<span className="aff-attentifs__call-text">APPELER* LE 3117</span>
<span className="aff-attentifs__call-hours">24h/24 · 7j/7</span>
</div>
<div className="aff-attentifs__call-footnote">
* Appel non surtaxé et enregistré
</div>
</div>
{/* Partie droite avec la bulle Alerte SNCF 3117 et le smartphone */}
<div className="aff-attentifs__bubble-section">
<div className="aff-attentifs__bubble-wrap">
{/* Bulle blanche à bordure rouge */}
<div className="aff-attentifs__bubble-card">
<div className="aff-attentifs__bubble-title">ALERTE SNCF</div>
<div className="aff-attentifs__bubble-rows">
<div className="aff-attentifs__bubble-line">
<span className="aff-attentifs__bubble-label">TÉL.</span>
<span className="aff-attentifs__bubble-number">3117*</span>
</div>
<div className="aff-attentifs__bubble-line">
<span className="aff-attentifs__bubble-label">SMS</span>
<span className="aff-attentifs__bubble-number">31177**</span>
</div>
</div>
{/* Pointe de la bulle */}
<div className="aff-attentifs__bubble-tail" />
</div>
{/* Smartphone Rouge d'alerte */}
<div className="aff-attentifs__phone-badge">
<svg viewBox="0 0 68 104" className="aff-attentifs__phone-svg" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Corps de téléphone rouge arrondi */}
<rect x="4" y="4" width="60" height="96" rx="14" fill="#e2001a" stroke="#ffffff" strokeWidth="4.5" />
{/* Écran blanc */}
<rect x="12" y="14" width="44" height="50" rx="6" fill="#ffffff" />
{/* Point d'exclamation rouge d'urgence */}
<rect x="30.5" y="24" width="7" height="20" rx="3.5" fill="#e2001a" />
<circle cx="34" cy="53" r="4" fill="#e2001a" />
{/* Touches du pavé numérique */}
<circle cx="20" cy="74" r="3" fill="#ffffff" />
<circle cx="34" cy="74" r="3" fill="#ffffff" />
<circle cx="48" cy="74" r="3" fill="#ffffff" />
<circle cx="20" cy="85" r="3" fill="#ffffff" />
<circle cx="34" cy="85" r="3" fill="#ffffff" />
<circle cx="48" cy="85" r="3" fill="#ffffff" />
</svg>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
.aff-dep-afl__container {
width: 100%;
height: 100%;
background-color: #d61e38;
color: #ffffff;
display: flex;
flex-direction: column;
user-select: none;
font-family: 'Achemine', 'Arial', sans-serif;
overflow: hidden;
box-sizing: border-box;
}
/* FRISSE SUPÉRIEURE RAYÉE */
.aff-dep-afl__hazard-stripes {
width: 100%;
height: 48px;
background: repeating-linear-gradient(
-45deg,
#ffffff,
#ffffff 28px,
#d61e38 28px,
#d61e38 56px
);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
flex-shrink: 0;
}
/* CONTENU PRINCIPAL */
.aff-dep-afl__content {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 40px 100px;
gap: 120px;
box-sizing: border-box;
}
.aff-dep-afl__icon-col {
flex: 0 0 460px;
display: flex;
align-items: center;
justify-content: center;
}
.aff-dep-afl__no-entry-svg {
width: 100%;
max-width: 440px;
height: auto;
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.35));
}
.aff-dep-afl__text-col {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 48px;
}
.aff-dep-afl__heading-group {
display: flex;
flex-direction: column;
line-height: 1.02;
}
.aff-dep-afl__title-main,
.aff-dep-afl__title-sub {
font-size: 102px;
font-weight: 900;
letter-spacing: 2px;
color: #ffffff;
text-transform: uppercase;
}
.aff-dep-afl__instructions {
display: flex;
flex-direction: column;
gap: 8px;
}
.aff-dep-afl__instruction-line {
font-size: 48px;
font-weight: 600;
line-height: 1.35;
color: #ffffff;
margin: 0;
}
@@ -0,0 +1,63 @@
import React from 'react';
import './DepartEnCoursAFL.css';
export default function DepartEnCoursAFL() {
return (
<div className="aff-dep-afl__container">
{/* RUBAN SUPÉRIEUR RAYÉ BLANC ET ROUGE */}
<div className="aff-dep-afl__hazard-stripes" />
{/* CONTENU PRINCIPAL */}
<div className="aff-dep-afl__content">
{/* Panneau d'interdiction blanc à gauche */}
<div className="aff-dep-afl__icon-col">
<svg
viewBox="0 0 240 240"
className="aff-dep-afl__no-entry-svg"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Cercle blanc extérieur */}
<circle cx="120" cy="120" r="105" stroke="#ffffff" strokeWidth="18" />
{/* Silhouette du piéton marchant avec valise */}
<g fill="#ffffff">
{/* Tête */}
<circle cx="126" cy="62" r="15" />
{/* Corps marchant */}
<path d="M116 84 C116 84, 134 84, 138 98 L152 144 L140 148 L128 110 L118 132 L112 186 L98 184 L106 126 L96 112 Z" />
{/* Jambe droite enjambant */}
<path d="M122 134 L138 180 C138 180, 146 186, 140 190 C134 194, 126 188, 126 188 L114 148 Z" />
{/* Bras gauche */}
<path d="M128 88 L146 114 L138 120 L122 96 Z" />
{/* Bras droit tenant la valise */}
<path d="M116 86 L98 116 L88 110" stroke="#ffffff" strokeWidth="8" strokeLinecap="round" strokeLinejoin="round" fill="none" />
{/* Valise inclinée */}
<g transform="rotate(-15, 80, 140)">
<rect x="58" y="122" width="38" height="52" rx="7" fill="#ffffff" />
<path d="M70 122 V112 H84 V122" stroke="#ffffff" strokeWidth="6" fill="none" />
</g>
</g>
{/* Barre diagonale blanche d'interdiction */}
<line x1="46" y1="46" x2="194" y2="194" stroke="#ffffff" strokeWidth="18" strokeLinecap="round" />
</svg>
</div>
{/* Textes d'alerte et de consigne à droite */}
<div className="aff-dep-afl__text-col">
<div className="aff-dep-afl__heading-group">
<div className="aff-dep-afl__title-main">DÉPARTS</div>
<div className="aff-dep-afl__title-sub">EN COURS</div>
</div>
<div className="aff-dep-afl__instructions">
<p className="aff-dep-afl__instruction-line">Pour votre sécurité</p>
<p className="aff-dep-afl__instruction-line">merci de vous éloigner</p>
<p className="aff-dep-afl__instruction-line">de la bordure du quai.</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
.aff-dep-classique__container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
user-select: none;
font-family: 'Achemine', 'Arial', sans-serif;
overflow: hidden;
box-sizing: border-box;
}
/* ZONE DU HAUT : GRIS FONCÉ (~72%) */
.aff-dep-classique__top {
flex: 1;
background-color: #222226;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 160px;
padding: 40px 100px;
box-sizing: border-box;
}
.aff-dep-classique__prohibition-sign {
display: flex;
align-items: center;
justify-content: center;
}
.aff-dep-classique__no-entry-svg {
width: 480px;
height: 480px;
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
.aff-dep-classique__train-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.aff-dep-classique__train-gif {
width: 480px;
height: auto;
max-height: 480px;
object-fit: contain;
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
.aff-dep-classique__train-svg {
width: 460px;
height: 460px;
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
/* BANDEAU ROUGE INFÉRIEUR (~28%) */
.aff-dep-classique__bottom {
flex: 0 0 280px;
background-color: #d0102a;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.35);
}
.aff-dep-classique__banner-text {
font-size: 110px;
font-weight: 900;
color: #ffffff;
margin: 0;
letter-spacing: 1px;
text-align: center;
}
@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import './DepartEnCoursClassique.css';
const GIF_URL = 'https://cdn.discordapp.com/attachments/1265325000290992258/1282018214720835725/SNCF_-_Depart_Train.gif?ex=6a95af01&is=6a945d81&hm=14570f1423217cb52437d767b3125a698ac5955fc088028fbc3d24d820e9a76b&';
export default function DepartEnCoursClassique() {
const [gifError, setGifError] = useState(false);
return (
<div className="aff-dep-classique__container">
{/* ZONE SUPÉRIEURE : PICTOS (FOND GRIS FONCÉ) */}
<div className="aff-dep-classique__top">
{/* Panneau d'interdiction : Ne pas monter (voyageur avec valise barré) */}
<div className="aff-dep-classique__prohibition-sign">
<svg viewBox="0 0 200 200" className="aff-dep-classique__no-entry-svg" xmlns="http://www.w3.org/2000/svg">
{/* Fond blanc du cercle */}
<circle cx="100" cy="100" r="90" fill="#ffffff" stroke="#d0102a" strokeWidth="18" />
{/* Silhouette du voyageur avec valise */}
<g fill="#141416">
{/* Tête */}
<circle cx="92" cy="52" r="14" />
{/* Corps */}
<rect x="76" y="70" width="32" height="52" rx="10" />
{/* Jambe gauche */}
<rect x="80" y="118" width="10" height="38" rx="5" />
{/* Jambe droite */}
<rect x="94" y="118" width="10" height="38" rx="5" />
{/* Bras gauche */}
<rect x="66" y="72" width="9" height="36" rx="4.5" />
{/* Bras droit tenant la poignée */}
<path d="M106 74 L120 102 L120 114" stroke="#141416" strokeWidth="8" strokeLinecap="round" strokeLinejoin="round" fill="none" />
{/* Poignée télescopique de valise */}
<path d="M116 102 V116 H124 V102" stroke="#141416" strokeWidth="4" fill="none" />
{/* Valise à roulettes */}
<rect x="110" y="116" width="24" height="38" rx="4" fill="#141416" />
{/* Roulettes */}
<circle cx="114" cy="156" r="3" fill="#141416" />
<circle cx="130" cy="156" r="3" fill="#141416" />
</g>
{/* Barre diagonale rouge d'interdiction */}
<line x1="36" y1="36" x2="164" y2="164" stroke="#d0102a" strokeWidth="18" strokeLinecap="round" />
</svg>
</div>
{/* Animation du train au départ (GIF ou SVG de secours) */}
<div className="aff-dep-classique__train-wrap">
{!gifError ? (
<img
src={GIF_URL}
alt="Train au départ"
className="aff-dep-classique__train-gif"
onError={() => setGifError(true)}
/>
) : (
<svg viewBox="0 0 240 240" className="aff-dep-classique__train-svg" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Rails et traverses */}
<path d="M40 200 L95 140 M200 200 L145 140" stroke="#ffffff" strokeWidth="8" strokeLinecap="round" />
<line x1="60" y1="185" x2="180" y2="185" stroke="#ffffff" strokeWidth="8" strokeLinecap="round" />
<line x1="85" y1="160" x2="155" y2="160" stroke="#ffffff" strokeWidth="6" strokeLinecap="round" />
{/* Face avant du train */}
<rect x="55" y="40" width="130" height="110" rx="35" fill="#ffffff" />
{/* Pare-brise */}
<rect x="68" y="52" width="104" height="42" rx="14" fill="#1c1c20" />
{/* Phares rouges */}
<circle cx="82" cy="120" r="10" fill="#d0102a" />
<circle cx="158" cy="120" r="10" fill="#d0102a" />
</svg>
)}
</div>
</div>
{/* BANDEAU INFÉRIEUR ROUGE : DÉPART EN COURS */}
<div className="aff-dep-classique__bottom">
<h1 className="aff-dep-classique__banner-text">Départ en cours</h1>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
.aff-videoprot__container {
width: 100%;
height: 100%;
background-color: #141416;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 80px;
box-sizing: border-box;
font-family: 'Achemine', 'Arial', sans-serif;
user-select: none;
}
.aff-videoprot__wrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 80px;
width: 100%;
max-width: 1600px;
}
.aff-videoprot__icon-col {
flex: 0 0 420px;
display: flex;
align-items: center;
justify-content: center;
}
.aff-videoprot__camera-svg {
width: 100%;
max-width: 420px;
height: auto;
filter: drop-shadow(0 6px 16px rgba(242, 91, 42, 0.25));
}
.aff-videoprot__text-col {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.aff-videoprot__title-vigi {
font-size: 52px;
font-weight: 800;
color: #f25b2a;
letter-spacing: 1.5px;
margin-bottom: 24px;
text-transform: uppercase;
}
.aff-videoprot__main-heading {
font-size: 46px;
font-weight: 800;
color: #ffffff;
line-height: 1.28;
letter-spacing: 0.5px;
margin: 0 0 54px 0;
text-transform: uppercase;
}
.aff-videoprot__subtext-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.aff-videoprot__legal-line,
.aff-videoprot__access-line {
font-size: 26px;
font-weight: 600;
line-height: 1.45;
color: #f0f0f0;
margin: 0;
}
@@ -0,0 +1,68 @@
import React from 'react';
import './GareVideoprotection.css';
export default function GareVideoprotection() {
return (
<div className="aff-videoprot__container">
<div className="aff-videoprot__wrapper">
{/* Caméra de surveillance stylisée orange */}
<div className="aff-videoprot__icon-col">
<svg
viewBox="0 0 320 240"
className="aff-videoprot__camera-svg"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Support mural */}
<rect x="20" y="105" width="22" height="75" rx="5" fill="#f25b2a" />
<path
d="M38 132 H80 L95 105 H125"
stroke="#f25b2a"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Corps de la caméra orienté vers le bas à droite */}
<g transform="rotate(18, 140, 110)">
{/* Corps principal */}
<rect x="35" y="55" width="165" height="70" rx="14" fill="#f25b2a" />
{/* Parasoleil / Visière supérieure */}
<path d="M30 48 L210 48 L195 58 L30 58 Z" fill="#f25b2a" />
{/* Cône avant / Objectif */}
<polygon points="198,62 250,38 250,142 198,118" fill="#f25b2a" />
{/* Fentes d'aération & détails noirs */}
<circle cx="65" cy="88" r="10" fill="#141416" />
<rect x="95" y="78" width="10" height="26" rx="4" fill="#141416" />
<rect x="120" y="78" width="10" height="26" rx="4" fill="#141416" />
<rect x="145" y="78" width="10" height="26" rx="4" fill="#141416" />
{/* Bague optique */}
<rect x="250" y="44" width="14" height="92" rx="3" fill="#f25b2a" />
</g>
</svg>
</div>
{/* Textes d'information et réglementaires */}
<div className="aff-videoprot__text-col">
<div className="aff-videoprot__title-vigi">VIGI360</div>
<h1 className="aff-videoprot__main-heading">
POUR VOTRE SÉCURITÉ, CET ESPACE<br />
EST ÉQUIPÉ DE VIDÉOPROTECTION.
</h1>
<div className="aff-videoprot__subtext-group">
<p className="aff-videoprot__legal-line">
En application des dispositions des textes législatifs et<br />
règlementaires en vigueur.
</p>
<p className="aff-videoprot__access-line">
Pour l'exercice du droit d'accès au enregistrements,<br />
s'adresser à un agent d'accueil de la gare.
</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,298 @@
/* =========================================================
BANDEAU COMPOSITION TGV (SNCF QUAI - Conforme Image 1 & 2)
========================================================= */
.aff-tgv-comp__container {
position: absolute;
bottom: 0;
left: 0;
width: 1920px;
height: 240px;
background-color: #4a0e4e; /* Couleur Prune / Aubergine SNCF authentique */
color: #ffffff;
font-family: 'Achemine', 'Avenir', 'Helvetica Neue', Arial, sans-serif;
box-sizing: border-box;
padding: 10px 35px 8px 35px;
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 25;
overflow: hidden;
}
/* =========================================================
TOP ROW : BOX VOIE + RAMES TGV
========================================================= */
.aff-tgv-comp__top-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 135px;
}
/* Box Voie à gauche */
.aff-tgv-comp__track-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120px;
min-width: 120px;
}
.aff-tgv-comp__track-pill {
background-color: #ffffff;
color: #4a0e4e;
font-size: 19px;
font-weight: 800;
text-transform: uppercase;
padding: 2px 14px;
border-radius: 12px;
line-height: 1.1;
letter-spacing: 0.5px;
}
.aff-tgv-comp__track-letter {
color: #ffffff;
font-size: 64px;
font-weight: 800;
line-height: 1;
margin-top: 4px;
margin-bottom: 4px;
text-align: center;
}
.aff-tgv-comp__track-underline {
width: 65px;
height: 3.5px;
background-color: #ffffff;
border-radius: 2px;
}
/* Conteneur des rames TGV */
.aff-tgv-comp__rames-container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 40px;
flex: 1;
}
/* Bloc d'une Rame */
.aff-tgv-comp__rame-block {
display: flex;
flex-direction: column;
align-items: center;
}
/* Titre Destination au-dessus de la rame (jaune doré) */
.aff-tgv-comp__dest-title {
color: #f5d130;
font-size: 24px;
font-weight: 700;
line-height: 1.1;
margin-bottom: 6px;
text-align: center;
white-space: nowrap;
}
/* Ligne des numéros de voitures */
.aff-tgv-comp__coaches-row {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 4px;
}
.aff-tgv-comp__coach-num-motrice {
width: 44px;
min-width: 44px;
}
.aff-tgv-comp__coach-num-box {
flex: 1;
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 19px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* Graphic de la rame TGV */
.aff-tgv-comp__train-graphic {
display: flex;
align-items: center;
height: 32px;
width: 100%;
}
/* Motrice avant */
.aff-tgv-comp__loco-front {
width: 44px;
height: 30px;
background: linear-gradient(180deg, #813988 0%, #5d1d64 100%);
border-top-left-radius: 16px;
border-bottom-left-radius: 12px;
margin-right: 2px;
}
/* Corps des voitures */
.aff-tgv-comp__coaches-body {
display: flex;
height: 30px;
flex: 1;
}
.aff-tgv-comp__coach-segment {
flex: 1;
min-width: 44px;
height: 30px;
background-color: #b776bd;
border-right: 2px solid #ffffff;
position: relative;
}
.aff-tgv-comp__coach-segment:first-child {
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.aff-tgv-comp__coach-segment:last-child {
border-right: none;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.aff-tgv-comp__coach-segment--bar {
background-color: #c98cd0;
}
/* Motrice arrière */
.aff-tgv-comp__loco-rear {
width: 44px;
height: 30px;
background: linear-gradient(180deg, #813988 0%, #5d1d64 100%);
border-top-right-radius: 16px;
border-bottom-right-radius: 12px;
margin-left: 2px;
}
/* =========================================================
LIGNE POINTILLÉE AVEC REPÈRES DE QUAI (Conforme Image 2)
========================================================= */
.aff-tgv-comp__reperes-line-wrapper {
position: relative;
width: 100%;
height: 28px;
display: flex;
align-items: center;
margin: 2px 0 4px 0;
}
.aff-tgv-comp__dotted-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
border-bottom: 2.5px dotted #f5d130;
transform: translateY(-50%);
z-index: 1;
}
.aff-tgv-comp__reperes-row {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 36px;
width: 100%;
padding-right: 40px;
box-sizing: border-box;
z-index: 2;
}
.aff-tgv-comp__repere-box {
background-color: #00337f;
border: 1.5px solid #ffffff;
border-radius: 4px;
color: #ffffff;
font-weight: 800;
font-size: 21px;
line-height: 1;
width: 32px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
/* =========================================================
BOTTOM ROW : MARQUEE + DIGITAL CLOCK
========================================================= */
.aff-tgv-comp__bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
width: 100%;
}
.aff-tgv-comp__marquee-wrapper {
flex: 1;
overflow: hidden;
white-space: nowrap;
margin-right: 30px;
}
.aff-tgv-comp__marquee-text {
display: inline-block;
color: #ffffff;
font-style: italic;
font-weight: 700;
font-size: 29px;
letter-spacing: 1px;
text-transform: uppercase;
animation: affTgvMarquee 25s linear infinite;
}
@keyframes affTgvMarquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
/* Horloge TGV */
.aff-tgv-comp__clock-wrapper {
display: flex;
align-items: center;
}
.aff-tgv-comp__clock {
background-color: #00337f;
border: 2px solid #ffffff;
border-radius: 4px;
padding: 3px 14px;
display: flex;
align-items: baseline;
gap: 4px;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.aff-tgv-comp__clock-hm {
color: #ffffff;
}
.aff-tgv-comp__clock-s {
color: #f5a623;
margin-left: 2px;
}
@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react';
import './TgvCompositionQuai.css';
/**
* Icône Bar / Coupe de verre
*/
function BarGlassIcon({ size = 20, color = '#ffffff' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 14L20 4H4Z" stroke={color} strokeWidth="2.2" strokeLinejoin="round" fill={color} />
<path d="M12 14V21M8 21H16" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
/**
* Horloge digitale avec secondes (conformité Image 1 & 2)
*/
function TgvDigitalClock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return (
<div className="aff-tgv-comp__clock">
<span className="aff-tgv-comp__clock-hm">{hh}:{mm}</span>
<span className="aff-tgv-comp__clock-s">{ss}</span>
</div>
);
}
/**
* Dessin d'une rame TGV de 8 voitures avec motrice avant, arrière et voiture Bar en position 4
*/
function TgvRameShape({ destination, coaches = ['01', '02', '03', 'bar', '05', '06', '07', '08'] }) {
return (
<div className="aff-tgv-comp__rame-block">
{destination && (
<div className="aff-tgv-comp__dest-title">
{destination}
</div>
)}
{/* Numéros des voitures */}
<div className="aff-tgv-comp__coaches-row">
{/* Espace motrice avant */}
<div className="aff-tgv-comp__coach-num-motrice" />
{coaches.map((c, idx) => (
<div key={idx} className="aff-tgv-comp__coach-num-box">
{c === 'bar' ? (
<BarGlassIcon size={20} color="#ffffff" />
) : (
<span>{c}</span>
)}
</div>
))}
{/* Espace motrice arrière */}
<div className="aff-tgv-comp__coach-num-motrice" />
</div>
{/* Silhouette du train TGV */}
<div className="aff-tgv-comp__train-graphic">
{/* Motrice avant arrondie */}
<div className="aff-tgv-comp__loco-front" />
{/* Voitures 1 à 8 */}
<div className="aff-tgv-comp__coaches-body">
{coaches.map((c, idx) => (
<div
key={idx}
className={`aff-tgv-comp__coach-segment ${c === 'bar' ? 'aff-tgv-comp__coach-segment--bar' : ''}`}
/>
))}
</div>
{/* Motrice arrière arrondie */}
<div className="aff-tgv-comp__loco-rear" />
</div>
</div>
);
}
export default function TgvCompositionQuai({
platform = '',
destination = '',
destination2 = '',
marqueeMessage = '',
composition = null,
reperes = ['R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']
}) {
const cleanTrack = String(platform || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
// Détermination des tranches (Rame 1 et Rame 2)
const isMultiDest = Boolean(destination2 && destination2.trim() !== '' && destination2.toLowerCase() !== destination.toLowerCase());
const rame1Coaches = (composition && composition.rame1) || ['01', '02', '03', 'bar', '05', '06', '07', '08'];
const rame2Coaches = (composition && composition.rame2) || ['11', '12', '13', 'bar', '15', '16', '17', '18'];
const defaultMarquee = marqueeMessage || 'MERCI DE VOTRE COMPRÉHENSION..........................................................................................';
return (
<div className="aff-tgv-comp__container">
{/* LIGNE DU HAUT : BOX VOIE + VISUALISATION DU TRAIN */}
<div className="aff-tgv-comp__top-row">
{/* Indicateur Voie à gauche */}
<div className="aff-tgv-comp__track-box">
<div className="aff-tgv-comp__track-pill">Voie</div>
<div className="aff-tgv-comp__track-letter">{cleanTrack ? cleanTrack.toUpperCase() : '-'}</div>
<div className="aff-tgv-comp__track-underline" />
</div>
{/* Visualisation Composition des Rames */}
<div className="aff-tgv-comp__rames-container">
<TgvRameShape
destination={destination}
coaches={rame1Coaches}
/>
<TgvRameShape
destination={isMultiDest ? destination2 : destination}
coaches={rame2Coaches}
/>
</div>
</div>
{/* LIGNE POINTILLÉE AVEC REPÈRES DE QUAI (Conforme Image 2) */}
<div className="aff-tgv-comp__reperes-line-wrapper">
<div className="aff-tgv-comp__dotted-line" />
{reperes && reperes.length > 0 && (
<div className="aff-tgv-comp__reperes-row">
{reperes.map((rep, idx) => (
<div key={idx} className="aff-tgv-comp__repere-box">
{rep}
</div>
))}
</div>
)}
</div>
{/* LIGNE DU BAS : DÉFILEMENT MESSAGE + HORLOGE */}
<div className="aff-tgv-comp__bottom-row">
<div className="aff-tgv-comp__marquee-wrapper">
<div className="aff-tgv-comp__marquee-text">
{defaultMarquee}
</div>
</div>
<div className="aff-tgv-comp__clock-wrapper">
<TgvDigitalClock />
</div>
</div>
</div>
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25 -1
View File
@@ -1,6 +1,30 @@
@font-face {
font-family: 'Achemine';
src: url('./fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('./fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('./fonts/achemine_italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: 'Achemine', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
+12
View File
@@ -15,3 +15,15 @@ root.render(
// to log results (for example: reportWebVitals(console.log)) // to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals(); reportWebVitals();
// Register PWA Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(registration => {
console.log('SW registered: ', registration);
registration.update();
}).catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
+98 -125
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import WcsButton from '../components/WcsButton'; import { WcsCard, WcsCardBody, WcsButton, WcsInput, WcsBadge, WcsHorizontalStepper } from 'wcs-react';
import sessionMemory from '../lib/sessionMemory'; import sessionMemory from '../lib/sessionMemory';
import { getSourceFromPath, searchGares } from '../services/railwayService'; import { getSourceFromPath, searchGares } from '../services/railwayService';
@@ -90,139 +90,112 @@ export default function Arrivees({ onBack, navigate }) {
} }
}; };
const steps = [
{ text: 'Choix de la gare', button: { kind: 'Text', text: '1' }, complete: false },
{ text: "Type d'afficheur", button: { kind: 'Text', text: '2' }, disable: true },
{ text: 'Options & Affichage', button: { kind: 'Text', text: '3' }, disable: true }
];
return ( return (
<div className="launcher-container"> <div style={{ maxWidth: '900px', margin: '0 auto', padding: '24px', overflowY: 'auto', maxHeight: 'calc(100vh - 120px)' }}>
{/* Stepper */} <WcsHorizontalStepper steps={steps} currentStep={0} style={{ marginBottom: '24px' }} />
<div className="launcher-stepper">
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">1</span>
<span>Choix de la gare</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">2</span>
<span>Type d'afficheur</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
<div className="launcher-card"> <WcsCard mode="raised">
{/* Header */} <WcsCardBody>
<div className="launcher-header"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div> <div>
<h1 className="launcher-title">🏁 Prochaines Arrivées</h1> <h1 style={{ margin: '0 0 8px 0', fontSize: '24px', color: 'var(--wcs-primary)' }}>🏁 Prochaines Arrivées</h1>
<p className="launcher-subtitle">Recherchez une gare pour afficher le tableau des arrivées en temps réel.</p> <p style={{ margin: 0, color: 'var(--wcs-text-light)' }}>Recherchez une gare pour afficher le tableau des arrivées en temps réel.</p>
</div>
<WcsBadge className={source === 'supabase' ? 'wcs-secondary' : 'wcs-primary'} shape="rounded">
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS Live'}
</WcsBadge>
</div> </div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS Live'}
</span>
</div>
{/* Barre de recherche */} <div style={{ marginBottom: '24px' }}>
<div className="launcher-search-box"> <WcsInput
<input type="text"
type="search" icon="search"
placeholder="Rechercher une gare (ex: Dijon, Paris, Lyon, Besançon...)" placeholder="Rechercher une gare (ex: Dijon, Paris, Lyon, Besançon...)"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onWcsInput={(e) => setSearch(e.target.value)}
className="launcher-search-input" style={{ width: '100%' }}
aria-label="Rechercher une gare" clearable={true}
autoFocus />
/>
{search && (
<button
type="button"
className="launcher-search-clear"
onClick={() => setSearch('')}
aria-label="Effacer la recherche"
>
✕
</button>
)}
</div>
{/* Suggestions rapides / Gares populaires */}
<div className="launcher-chips-container">
<span className="launcher-chips-label">Gares fréquentes :</span>
<div className="launcher-chips-list">
{POPULAR_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => handleSelectGare(gName)}
>
{gName.replace(/-/g, ' ')}
</button>
))}
</div> </div>
</div>
{loading && <p style={{ color: '#00439e', fontWeight: 600 }}>⏳ Chargement des gares...</p>} <div style={{ marginBottom: '24px' }}>
{error && <p style={{ color: '#d70022', fontWeight: 600 }}>⚠️ Erreur : {error}</p>} <p style={{ fontWeight: 'bold', marginBottom: '12px' }}>Gares fréquentes :</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{/* Invitation à la recherche en mode SNCF */} {POPULAR_GARES.map((gName) => (
{source === 'sncf' && search.trim().length < 2 && ( <WcsButton
<div style={{ textAlign: 'center', padding: '24px 16px', color: '#666', background: '#f8fafc', borderRadius: '8px', border: '1px dashed #d0d7de' }}> key={gName}
<p style={{ margin: 0, fontSize: '14px' }}> mode="stroked"
💡 Saisissez au moins <strong>2 lettres</strong> pour chercher parmi plus de 3 500 gares du réseau français. shape="round"
</p> size="s"
</div> onClick={() => handleSelectGare(gName)}
)}
{/* Aucun résultat */}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: '#777', background: '#fff', borderRadius: '8px' }}>
Aucune gare trouvée pour « <strong>{search}</strong> ».
</div>
)}
{/* Liste des résultats */}
{displayedGares.length > 0 && (
<div className="launcher-results-list" role="list">
{displayedGares.map((g) => {
const nom = g.nom;
return (
<div
key={g.uic || nom}
className="launcher-result-item"
onClick={() => handleSelectGare(nom)}
onKeyDown={(e) => (e.key === 'Enter' ? handleSelectGare(nom) : null)}
tabIndex={0}
role="listitem"
> >
<div> {gName.replace(/-/g, ' ')}
<div className="launcher-result-name">{nom}</div> </WcsButton>
{g.commune && ( ))}
<div className="launcher-result-sub"> </div>
{g.commune} {g.departement ? `(${g.departement})` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <span className="launcher-result-tag">UIC {g.uic}</span>}
<span style={{ color: '#00439e', fontSize: '18px', fontWeight: 'bold' }}>›</span>
</div>
</div>
);
})}
</div> </div>
)}
{/* Navigation retour */} {loading && <p style={{ color: 'var(--wcs-primary)', fontWeight: 600 }}>⏳ Chargement des gares...</p>}
<div className="launcher-footer-nav"> {error && <p style={{ color: 'var(--wcs-danger)', fontWeight: 600 }}>⚠️ Erreur : {error}</p>}
<WcsButton
mode="plain" {source === 'sncf' && search.trim().length < 2 && (
onClick={onBack} <div style={{ textAlign: 'center', padding: '24px 16px', color: 'var(--wcs-text-light)', background: 'var(--wcs-light)', borderRadius: '8px', border: '1px dashed var(--wcs-gray)' }}>
label="← Retour à l'accueil" <p style={{ margin: 0, fontSize: '14px' }}>
/> 💡 Saisissez au moins <strong>2 lettres</strong> pour chercher parmi plus de 3 500 gares du réseau français.
</div> </p>
</div> </div>
)}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: 'var(--wcs-text-light)', background: 'var(--wcs-white)', borderRadius: '8px' }}>
Aucune gare trouvée pour « <strong>{search}</strong> ».
</div>
)}
{displayedGares.length > 0 && (
<div role="list" style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '24px' }}>
{displayedGares.map((g) => {
const nom = g.nom;
return (
<WcsCard
key={g.uic || nom}
mode="flat"
style={{ cursor: 'pointer', border: '1px solid var(--wcs-light)' }}
onClick={() => handleSelectGare(nom)}
>
<WcsCardBody style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 'bold', fontSize: '16px', color: 'var(--wcs-primary)' }}>{nom}</div>
{g.commune && (
<div style={{ fontSize: '13px', color: 'var(--wcs-text-light)' }}>
{g.commune} {g.departement ? `(${g.departement})` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <WcsBadge>{g.uic}</WcsBadge>}
<span style={{ color: 'var(--wcs-primary)', fontSize: '20px', fontWeight: 'bold' }}>›</span>
</div>
</WcsCardBody>
</WcsCard>
);
})}
</div>
)}
<div style={{ borderTop: '1px solid var(--wcs-light)', paddingTop: '16px' }}>
<WcsButton mode="clear" onClick={onBack}>
← Retour à l'accueil
</WcsButton>
</div>
</WcsCardBody>
</WcsCard>
</div> </div>
); );
} }
+98 -125
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import WcsButton from '../components/WcsButton'; import { WcsCard, WcsCardBody, WcsButton, WcsInput, WcsBadge, WcsHorizontalStepper } from 'wcs-react';
import sessionMemory from '../lib/sessionMemory'; import sessionMemory from '../lib/sessionMemory';
import { getSourceFromPath, searchGares } from '../services/railwayService'; import { getSourceFromPath, searchGares } from '../services/railwayService';
@@ -90,139 +90,112 @@ export default function Departs({ onBack, navigate }) {
} }
}; };
const steps = [
{ text: 'Choix de la gare', button: { kind: 'Text', text: '1' }, complete: false },
{ text: "Type d'afficheur", button: { kind: 'Text', text: '2' }, disable: true },
{ text: 'Options & Affichage', button: { kind: 'Text', text: '3' }, disable: true }
];
return ( return (
<div className="launcher-container"> <div style={{ maxWidth: '900px', margin: '0 auto', padding: '24px', overflowY: 'auto', maxHeight: 'calc(100vh - 120px)' }}>
{/* Stepper */} <WcsHorizontalStepper steps={steps} currentStep={0} style={{ marginBottom: '24px' }} />
<div className="launcher-stepper">
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">1</span>
<span>Choix de la gare</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">2</span>
<span>Type d'afficheur</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
<div className="launcher-card"> <WcsCard mode="raised">
{/* Header */} <WcsCardBody>
<div className="launcher-header"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div> <div>
<h1 className="launcher-title">🚆 Prochains Départs</h1> <h1 style={{ margin: '0 0 8px 0', fontSize: '24px', color: 'var(--wcs-primary)' }}>🚆 Prochains Départs</h1>
<p className="launcher-subtitle">Recherchez une gare pour afficher le tableau des départs en temps réel.</p> <p style={{ margin: 0, color: 'var(--wcs-text-light)' }}>Recherchez une gare pour afficher le tableau des départs en temps réel.</p>
</div>
<WcsBadge className={source === 'supabase' ? 'wcs-secondary' : 'wcs-primary'} shape="rounded">
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS Live'}
</WcsBadge>
</div> </div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS Live'}
</span>
</div>
{/* Barre de recherche */} <div style={{ marginBottom: '24px' }}>
<div className="launcher-search-box"> <WcsInput
<input type="text"
type="search" icon="search"
placeholder="Rechercher une gare (ex: Dijon, Paris, Lyon, Besançon...)" placeholder="Rechercher une gare (ex: Dijon, Paris, Lyon, Besançon...)"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onWcsInput={(e) => setSearch(e.target.value)}
className="launcher-search-input" style={{ width: '100%' }}
aria-label="Rechercher une gare" clearable={true}
autoFocus />
/>
{search && (
<button
type="button"
className="launcher-search-clear"
onClick={() => setSearch('')}
aria-label="Effacer la recherche"
>
✕
</button>
)}
</div>
{/* Suggestions rapides / Gares populaires */}
<div className="launcher-chips-container">
<span className="launcher-chips-label">Gares fréquentes :</span>
<div className="launcher-chips-list">
{POPULAR_GARES.map((gName) => (
<button
key={gName}
type="button"
className="launcher-chip"
onClick={() => handleSelectGare(gName)}
>
{gName.replace(/-/g, ' ')}
</button>
))}
</div> </div>
</div>
{loading && <p style={{ color: '#00439e', fontWeight: 600 }}>⏳ Chargement des gares...</p>} <div style={{ marginBottom: '24px' }}>
{error && <p style={{ color: '#d70022', fontWeight: 600 }}>⚠️ Erreur : {error}</p>} <p style={{ fontWeight: 'bold', marginBottom: '12px' }}>Gares fréquentes :</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{/* Invitation à la recherche en mode SNCF */} {POPULAR_GARES.map((gName) => (
{source === 'sncf' && search.trim().length < 2 && ( <WcsButton
<div style={{ textAlign: 'center', padding: '24px 16px', color: '#666', background: '#f8fafc', borderRadius: '8px', border: '1px dashed #d0d7de' }}> key={gName}
<p style={{ margin: 0, fontSize: '14px' }}> mode="stroked"
💡 Saisissez au moins <strong>2 lettres</strong> pour chercher parmi plus de 3 500 gares du réseau français. shape="round"
</p> size="s"
</div> onClick={() => handleSelectGare(gName)}
)}
{/* Aucun résultat */}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: '#777', background: '#fff', borderRadius: '8px' }}>
Aucune gare trouvée pour « <strong>{search}</strong> ».
</div>
)}
{/* Liste des résultats */}
{displayedGares.length > 0 && (
<div className="launcher-results-list" role="list">
{displayedGares.map((g) => {
const nom = g.nom;
return (
<div
key={g.uic || nom}
className="launcher-result-item"
onClick={() => handleSelectGare(nom)}
onKeyDown={(e) => (e.key === 'Enter' ? handleSelectGare(nom) : null)}
tabIndex={0}
role="listitem"
> >
<div> {gName.replace(/-/g, ' ')}
<div className="launcher-result-name">{nom}</div> </WcsButton>
{g.commune && ( ))}
<div className="launcher-result-sub"> </div>
{g.commune} {g.departement ? `(${g.departement})` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <span className="launcher-result-tag">UIC {g.uic}</span>}
<span style={{ color: '#00439e', fontSize: '18px', fontWeight: 'bold' }}>›</span>
</div>
</div>
);
})}
</div> </div>
)}
{/* Navigation retour */} {loading && <p style={{ color: 'var(--wcs-primary)', fontWeight: 600 }}>⏳ Chargement des gares...</p>}
<div className="launcher-footer-nav"> {error && <p style={{ color: 'var(--wcs-danger)', fontWeight: 600 }}>⚠️ Erreur : {error}</p>}
<WcsButton
mode="plain" {source === 'sncf' && search.trim().length < 2 && (
onClick={onBack} <div style={{ textAlign: 'center', padding: '24px 16px', color: 'var(--wcs-text-light)', background: 'var(--wcs-light)', borderRadius: '8px', border: '1px dashed var(--wcs-gray)' }}>
label="← Retour à l'accueil" <p style={{ margin: 0, fontSize: '14px' }}>
/> 💡 Saisissez au moins <strong>2 lettres</strong> pour chercher parmi plus de 3 500 gares du réseau français.
</div> </p>
</div> </div>
)}
{source === 'sncf' && search.trim().length >= 2 && displayedGares.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: 'var(--wcs-text-light)', background: 'var(--wcs-white)', borderRadius: '8px' }}>
Aucune gare trouvée pour « <strong>{search}</strong> ».
</div>
)}
{displayedGares.length > 0 && (
<div role="list" style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '24px' }}>
{displayedGares.map((g) => {
const nom = g.nom;
return (
<WcsCard
key={g.uic || nom}
mode="flat"
style={{ cursor: 'pointer', border: '1px solid var(--wcs-light)' }}
onClick={() => handleSelectGare(nom)}
>
<WcsCardBody style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 'bold', fontSize: '16px', color: 'var(--wcs-primary)' }}>{nom}</div>
{g.commune && (
<div style={{ fontSize: '13px', color: 'var(--wcs-text-light)' }}>
{g.commune} {g.departement ? `(${g.departement})` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{g.uic && <WcsBadge>{g.uic}</WcsBadge>}
<span style={{ color: 'var(--wcs-primary)', fontSize: '20px', fontWeight: 'bold' }}>›</span>
</div>
</WcsCardBody>
</WcsCard>
);
})}
</div>
)}
<div style={{ borderTop: '1px solid var(--wcs-light)', paddingTop: '16px' }}>
<WcsButton mode="clear" onClick={onBack}>
← Retour à l'accueil
</WcsButton>
</div>
</WcsCardBody>
</WcsCard>
</div> </div>
); );
} }
+62 -87
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import WcsButton from '../components/WcsButton'; import { WcsCard, WcsCardBody, WcsButton, WcsBadge, WcsHorizontalStepper } from 'wcs-react';
import sessionMemory from '../lib/sessionMemory'; import sessionMemory from '../lib/sessionMemory';
import { getSourceFromPath } from '../services/railwayService'; import { getSourceFromPath } from '../services/railwayService';
@@ -33,7 +33,7 @@ export default function Displays({ onBack, navigate }) {
tag: 'Gares Connectées', tag: 'Gares Connectées',
tagType: 'modern', tagType: 'modern',
icon: '📱', icon: '📱',
desc: 'Écrans d\'information enrichis avec multimédia, infogare et dessertes complètes.' desc: "Écrans d'information enrichis avec multimédia, infogare et dessertes complètes."
}, },
{ {
id: 'transilien', id: 'transilien',
@@ -46,8 +46,7 @@ export default function Displays({ onBack, navigate }) {
} }
]; ];
// Nettoyage du chemin pour extraire la gare même avec /ferrovia const cleanPath = (typeof window !== 'undefined' ? window.location.pathname : '').replace(/ferrovia/, '');
const cleanPath = (typeof window !== 'undefined' ? window.location.pathname : '').replace(/^\/ferrovia/, '');
const parts = cleanPath.split('/').filter(Boolean); const parts = cleanPath.split('/').filter(Boolean);
const typeIds = types.map(t => t.id); const typeIds = types.map(t => t.id);
let gare = null; let gare = null;
@@ -63,14 +62,13 @@ export default function Displays({ onBack, navigate }) {
const g = paramsForGare.get('gare'); const g = paramsForGare.get('gare');
if (g) { if (g) {
try { try {
gare = decodeURIComponent(String(g).replace(/\+/g, ' ')).replace(/-/g, ' '); gare = decodeURIComponent(String(g).replace(/\\+/g, ' ')).replace(/-/g, ' ');
} catch (e) { } catch (e) {
gare = String(g).replace(/-/g, ' '); gare = String(g).replace(/-/g, ' ');
} }
} }
} }
// Déterminer le mode (departs/arrivees)
const params = new URLSearchParams(window.location.search || ''); const params = new URLSearchParams(window.location.search || '');
let mode = params.get('mode'); let mode = params.get('mode');
if (!mode) { if (!mode) {
@@ -83,96 +81,73 @@ export default function Displays({ onBack, navigate }) {
const formattedGareName = gare ? gare.replace(/-/g, ' ') : 'Non sélectionnée'; const formattedGareName = gare ? gare.replace(/-/g, ' ') : 'Non sélectionnée';
const steps = [
{ text: formattedGareName, button: { kind: 'Icon', iconName: 'check' }, complete: true },
{ text: "Type d'afficheur", button: { kind: 'Text', text: '2' }, complete: false },
{ text: 'Options & Affichage', button: { kind: 'Text', text: '3' }, disable: true }
];
return ( return (
<div className="launcher-container"> <div style={{ maxWidth: '900px', margin: '0 auto', padding: '24px', overflowY: 'auto', maxHeight: 'calc(100vh - 120px)' }}>
{/* Stepper */} <WcsHorizontalStepper steps={steps} currentStep={1} style={{ marginBottom: '24px' }} />
<div className="launcher-stepper">
<div className="launcher-step launcher-step--completed">
<span className="launcher-step-num">✓</span>
<span>{formattedGareName}</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">2</span>
<span>Type d'afficheur</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
<div className="launcher-card"> <WcsCard mode="raised">
{/* Header */} <WcsCardBody>
<div className="launcher-header"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div> <div>
<h1 className="launcher-title">Sélectionnez le style d'afficheur</h1> <h1 style={{ margin: '0 0 8px 0', fontSize: '24px', color: 'var(--wcs-primary)' }}>Sélectionnez le style d'afficheur</h1>
<p className="launcher-subtitle"> <p style={{ margin: 0, color: 'var(--wcs-text-light)' }}>
Gare : <strong style={{ color: '#002257' }}>{formattedGareName}</strong> • Mode : <strong style={{ color: '#00439e' }}>{mode === 'arrivees' ? '🏁 Arrivées' : '🚆 Départs'}</strong> Gare : <strong>{formattedGareName}</strong> • Mode : <strong>{mode === 'arrivees' ? '🏁 Arrivées' : '🚆 Départs'}</strong>
</p> </p>
</div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS'}
</span>
</div>
{/* Grille de sélection tactile des styles d'afficheurs */}
<div className="launcher-grid launcher-grid--4col">
{types.map((t) => (
<div
key={t.id}
className="launcher-select-card"
role="button"
tabIndex={0}
onClick={() => {
if (typeof navigate === 'function') {
const kebab = gare ? String(gare).trim().replace(/\s+/g, '-').replace(/\//g, '/') : '';
navigate(`${prefix}/select-afficheur?gare=${encodeURIComponent(kebab)}&mode=${mode}&type=${t.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
const kebab = gare ? String(gare).trim().replace(/\s+/g, '-').replace(/\//g, '/') : '';
navigate(`${prefix}/select-afficheur?gare=${encodeURIComponent(kebab)}&mode=${mode}&type=${t.id}`);
}
}}
>
<div>
<span className="launcher-card-icon">{t.icon}</span>
<span className={`launcher-card-tag ${t.tagType === 'retro' ? 'launcher-card-tag--retro' : 'launcher-card-tag--modern'}`}>
{t.tag}
</span>
<h2 className="launcher-card-title">{t.label}</h2>
<div style={{ fontSize: '13px', fontWeight: 600, color: '#00439e', marginBottom: '8px' }}>{t.sublabel}</div>
<p className="launcher-card-desc">{t.desc}</p>
</div>
<div style={{ marginTop: 12 }}>
<span className="launcher-btn launcher-btn--secondary" style={{ padding: '8px 12px', fontSize: '14px' }}>
Choisir ce style ›
</span>
</div>
</div> </div>
))} <WcsBadge className={source === 'supabase' ? 'wcs-secondary' : 'wcs-primary'} shape="rounded">
</div> {source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS'}
</WcsBadge>
</div>
{/* Navigation retour */} <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<div className="launcher-footer-nav"> {types.map((t) => (
<WcsButton <WcsCard
mode="plain" key={t.id}
onClick={() => { mode="flat"
style={{ cursor: 'pointer', border: '1px solid var(--wcs-light)' }}
onClick={() => {
if (typeof navigate === 'function') {
const kebab = gare ? String(gare).trim().replace(/\\s+/g, '-').replace(/\\/g, '/') : '';
navigate(`${prefix}/select-afficheur?gare=${encodeURIComponent(kebab)}&mode=${mode}&type=${t.id}`);
}
}}
>
<WcsCardBody style={{ padding: '16px', display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: '12px' }}>
<span style={{ fontSize: '32px', marginRight: '8px' }}>{t.icon}</span>
<WcsBadge className={t.tagType === 'retro' ? 'wcs-secondary' : 'wcs-success'}>{t.tag}</WcsBadge>
</div>
<h2 style={{ fontSize: '18px', margin: '0 0 4px 0', color: 'var(--wcs-primary)' }}>{t.label}</h2>
<div style={{ fontSize: '13px', fontWeight: 'bold', color: 'var(--wcs-primary)', marginBottom: '8px' }}>{t.sublabel}</div>
<p style={{ fontSize: '14px', color: 'var(--wcs-text-light)', flexGrow: 1, marginBottom: '16px' }}>{t.desc}</p>
<WcsButton className="wcs-secondary" style={{ width: '100%', marginTop: 'auto' }}>
Choisir ce style
</WcsButton>
</WcsCardBody>
</WcsCard>
))}
</div>
<div style={{ borderTop: '1px solid var(--wcs-light)', paddingTop: '16px' }}>
<WcsButton mode="clear" onClick={() => {
if (typeof onBack === 'function') { if (typeof onBack === 'function') {
onBack(); onBack();
} else if (navigate) { } else if (navigate) {
navigate(`${prefix}/${mode}`); navigate(`${prefix}/${mode}`);
} }
}} }}>
label="← Changer de gare" ← Changer de gare
/> </WcsButton>
</div> </div>
</div> </WcsCardBody>
</WcsCard>
</div> </div>
); );
} }
+189 -311
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import WcsButton from '../components/WcsButton'; import { WcsCard, WcsCardBody, WcsButton, WcsBadge, WcsCheckbox, WcsSelect, WcsSelectOption, WcsInput, WcsHorizontalStepper } from 'wcs-react';
import { getSourceFromPath, getPlatformsForStation } from '../services/railwayService'; import { getSourceFromPath, getPlatformsForStation } from '../services/railwayService';
export default function SelectAfficheur({ onBack, navigate }) { export default function SelectAfficheur({ onBack, navigate }) {
@@ -16,7 +16,6 @@ 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({ const [aflPages, setAflPages] = useState({
horaires: true, horaires: true,
infos: false, infos: false,
@@ -25,8 +24,7 @@ export default function SelectAfficheur({ onBack, navigate }) {
corresp: false corresp: false
}); });
// Options spécifiques pour les afficheurs Classiques const [extendedLines, setExtendedLines] = useState(0);
const [extendedLines, setExtendedLines] = useState(0); // 0, 1, 2, 3
const [showTime, setShowTime] = useState(false); const [showTime, setShowTime] = useState(false);
const toggleAflPage = (key) => { const toggleAflPage = (key) => {
@@ -43,9 +41,8 @@ export default function SelectAfficheur({ onBack, navigate }) {
}); });
}; };
// 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, ' ')
: ''; : '';
useEffect(() => { useEffect(() => {
@@ -129,331 +126,212 @@ export default function SelectAfficheur({ onBack, navigate }) {
} }
}; };
const steps = [
{ text: decodedGare || 'Gare', button: { kind: 'Icon', iconName: 'check' }, complete: true },
{ text: typeParam.toUpperCase(), button: { kind: 'Icon', iconName: 'check' }, complete: true },
{ text: 'Options & Affichage', button: { kind: 'Text', text: '3' }, complete: false }
];
return ( return (
<div className="launcher-container"> <div style={{ maxWidth: '900px', margin: '0 auto', padding: '24px' }}>
{/* Stepper */} <WcsHorizontalStepper steps={steps} currentStep={2} style={{ marginBottom: '24px' }} />
<div className="launcher-stepper">
<div className="launcher-step launcher-step--completed">
<span className="launcher-step-num">✓</span>
<span>{decodedGare || 'Gare'}</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step launcher-step--completed">
<span className="launcher-step-num">✓</span>
<span>{typeParam.toUpperCase()}</span>
</div>
<span className="launcher-step-separator">›</span>
<div className="launcher-step launcher-step--active">
<span className="launcher-step-num">3</span>
<span>Options & Affichage</span>
</div>
</div>
<div className="launcher-card"> <WcsCard mode="raised">
{/* Header */} <WcsCardBody>
<div className="launcher-header"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
<div>
<h1 className="launcher-title">Configuration de l'afficheur</h1>
<p className="launcher-subtitle">
Gare : <strong style={{ color: '#002257' }}>{decodedGare}</strong> • Mode : <strong style={{ color: '#00439e' }}>{modeParam === 'arrivees' ? '🏁 Arrivées' : '🚆 Départs'}</strong> • Style : <strong style={{ color: '#008b47' }}>{typeParam.toUpperCase()}</strong>
</p>
</div>
<span className={`launcher-badge ${source === 'supabase' ? 'launcher-badge--supabase' : ''}`}>
{source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS'}
</span>
</div>
{/* Grille responsive 2 colonnes (1 col sur mobile) */}
<div className="launcher-grid launcher-grid--2col">
{/* Option 1 : Afficheur Général */}
<div className="launcher-select-card" style={{ cursor: 'default' }}>
<div> <div>
<span className="launcher-card-icon">🖥️</span> <h1 style={{ margin: '0 0 8px 0', fontSize: '24px', color: 'var(--wcs-primary)' }}>Configuration de l'afficheur</h1>
<span className="launcher-card-tag launcher-card-tag--modern">Écran Général</span> <p style={{ margin: 0, color: 'var(--wcs-text-light)' }}>
<h2 className="launcher-card-title">Afficheur Général (Hall)</h2> Gare : <strong>{decodedGare}</strong> • Mode : <strong>{modeParam === 'arrivees' ? '🏁 Arrivées' : '🚆 Départs'}</strong> • Style : <strong>{typeParam.toUpperCase()}</strong>
<p className="launcher-card-desc">
Affiche la liste complète des circulations avec basculement automatique des pages.
</p> </p>
{typeParam === 'afl' && (
<div className="launcher-checklist-box">
<div className="launcher-checklist-header">
<span className="launcher-checklist-title">
Pages incluses dans la boucle :
</span>
<div className="launcher-checklist-actions">
<button
type="button"
onClick={() => selectAllAflPages(true)}
className="launcher-link-btn"
>
Tout cocher
</button>
<span style={{ color: '#ccc' }}>|</span>
<button
type="button"
onClick={() => selectAllAflPages(false)}
className="launcher-link-btn"
style={{ color: '#666' }}
>
Horaires seuls
</button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.horaires}
onChange={() => toggleAflPage('horaires')}
className="launcher-checkbox"
/>
<span><strong>Page 1 & 2</strong> (Horaires des trains)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.infos}
onChange={() => toggleAflPage('infos')}
className="launcher-checkbox"
/>
<span><strong>Informations</strong> (Trafic & Travaux)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.securite1}
onChange={() => toggleAflPage('securite1')}
className="launcher-checkbox"
/>
<span><strong>Sécurité 1</strong> (Bagages délaissés)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.securite2}
onChange={() => toggleAflPage('securite2')}
className="launcher-checkbox"
/>
<span><strong>Sécurité 2</strong> (Vigipirate / 3117)</span>
</label>
<label className="launcher-checkbox-label">
<input
type="checkbox"
checked={aflPages.corresp}
onChange={() => toggleAflPage('corresp')}
className="launcher-checkbox"
/>
<span><strong>Correspondances</strong> (Bus & Tram)</span>
</label>
</div>
</div>
)}
{typeParam === 'classiques' && (
<div className="launcher-checklist-box">
<div className="launcher-checklist-header">
<span className="launcher-checklist-title">
Options d'affichage Classiques :
</span>
</div>
{/* Choix du nombre de lignes étendues */}
<div style={{ marginBottom: '14px' }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#002257', marginBottom: '8px' }}>
Lignes étendues (Dessertes défilantes) :
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px' }}>
{[0, 1, 2, 3].map((num) => (
<button
key={num}
type="button"
onClick={() => setExtendedLines(num)}
style={{
padding: '8px 4px',
borderRadius: '8px',
border: extendedLines === num ? '2px solid #00439e' : '1px solid #c2dbfe',
background: extendedLines === num ? '#00439e' : '#ffffff',
color: extendedLines === num ? '#ffffff' : '#002257',
fontWeight: 700,
fontSize: '13px',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
{num === 0 ? '0 (Standard)' : `${num} étendue${num > 1 ? 's' : ''}`}
</button>
))}
</div>
<div style={{ fontSize: '12px', color: '#555', marginTop: '6px' }}>
{extendedLines > 0
? `Les ${extendedLines} premier${extendedLines > 1 ? 's' : ''} train${extendedLines > 1 ? 's' : ''} s'afficheront sur 2 lignes réduites avec le défilement de toutes les gares.`
: 'Affichage classique compact sans défilement de gares.'}
</div>
</div>
{/* Option Afficher l'heure */}
<div style={{ borderTop: '1px solid #e2ebf6', paddingTop: '10px' }}>
<label className="launcher-checkbox-label" style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={showTime}
onChange={(e) => setShowTime(e.target.checked)}
className="launcher-checkbox"
/>
<span>
<strong>Afficher l'heure & informations</strong>
</span>
</label>
<div style={{ fontSize: '12px', color: '#555', marginLeft: '28px', marginTop: '2px' }}>
Réserve la 10e ligne pour l'horloge numérique [ HH MM SS ] et un bandeau d'information défilant (horaires sur 9 lignes max).
</div>
</div>
</div>
)}
</div> </div>
<WcsBadge className={source === 'supabase' ? 'wcs-secondary' : 'wcs-primary'} shape="rounded">
<button {source === 'supabase' ? '🗄️ BDD Ferrovia' : '🌐 SNCF GTFS'}
type="button" </WcsBadge>
onClick={handleGeneral}
className="launcher-btn launcher-btn--primary"
>
🖥️ Lancer l'Afficheur Général
</button>
</div> </div>
{/* Option 2 : Afficheur par Quai */} <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '24px', marginBottom: '24px' }}>
<div className="launcher-select-card" style={{ cursor: 'default' }}>
<div>
<span className="launcher-card-icon">🚉</span>
<span className="launcher-card-tag launcher-card-tag--retro">Écran de Voie</span>
<h2 className="launcher-card-title">Afficheur par Quai</h2>
<p className="launcher-card-desc">
Affiche le prochain train au départ/arrivée avec la liste déroulante des gares desservies sur la voie.
</p>
<div style={{ background: '#f8fafc', border: '1px solid #e2ebf6', borderRadius: '10px', padding: '16px', marginBottom: '20px' }}> <WcsCard mode="flat" style={{ border: '1px solid var(--wcs-light)' }}>
{loading ? ( <WcsCardBody style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ fontSize: '14px', color: '#00439e', fontWeight: 600 }}>⏳ Détection des voies réelles...</div> <div style={{ marginBottom: '16px' }}>
) : ( <span style={{ fontSize: '24px', marginRight: '8px' }}>🖥️</span>
<div> <WcsBadge className="wcs-info">Écran Général</WcsBadge>
{platforms.length > 0 ? ( </div>
<div style={{ marginBottom: '16px' }}> <h2 style={{ fontSize: '20px', margin: '0 0 8px 0' }}>Afficheur Général (Hall)</h2>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#002257', marginBottom: '6px' }}> <p style={{ fontSize: '14px', color: 'var(--wcs-text-light)', marginBottom: '16px' }}>
Voies actives détectées : Affiche la liste complète des circulations avec basculement automatique des pages.
</label> </p>
<select
value={selectedPlatform}
onChange={(e) => {
setSelectedPlatform(e.target.value);
setManualPlatform('');
}}
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
border: '2px solid #c2dbfe',
fontSize: '15px',
fontWeight: 600,
background: '#ffffff',
color: '#002257'
}}
>
{platforms.map(p => (
<option key={p} value={p}>Voie {p}</option>
))}
</select>
</div>
) : (
<div style={{ fontSize: '13px', color: '#666', marginBottom: '16px', background: '#fff', padding: '8px 12px', borderRadius: '6px' }}>
ℹ️ Saisie manuelle de la voie ci-dessous.
</div>
)}
<div> {typeParam === 'afl' && (
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#002257', marginBottom: '6px' }}> <div style={{ background: 'var(--wcs-light)', padding: '16px', borderRadius: '4px', marginBottom: '16px', flexGrow: 1 }}>
Ou saisie libre d'une voie : <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
</label> <span style={{ fontWeight: 'bold' }}>Pages incluses dans la boucle :</span>
<input <div>
type="text" <WcsButton mode="clear" size="s" onClick={() => selectAllAflPages(true)}>Tout cocher</WcsButton>
placeholder="Ex: A, B, 1, 2 bis..." <WcsButton mode="clear" size="s" onClick={() => selectAllAflPages(false)}>Horaires seuls</WcsButton>
value={manualPlatform} </div>
onChange={(e) => setManualPlatform(e.target.value)} </div>
style={{ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
width: '100%', <WcsCheckbox checked={aflPages.horaires} onWcsChange={() => toggleAflPage('horaires')}><strong>Page 1 & 2</strong> (Horaires)</WcsCheckbox>
padding: '12px', <WcsCheckbox checked={aflPages.infos} onWcsChange={() => toggleAflPage('infos')}><strong>Informations</strong> (Trafic)</WcsCheckbox>
borderRadius: '8px', <WcsCheckbox checked={aflPages.securite1} onWcsChange={() => toggleAflPage('securite1')}><strong>Sécurité 1</strong> (Bagages)</WcsCheckbox>
border: '2px solid #e2ebf6', <WcsCheckbox checked={aflPages.securite2} onWcsChange={() => toggleAflPage('securite2')}><strong>Sécurité 2</strong> (Vigipirate)</WcsCheckbox>
fontSize: '15px', <WcsCheckbox checked={aflPages.corresp} onWcsChange={() => toggleAflPage('corresp')}><strong>Correspondances</strong></WcsCheckbox>
boxSizing: 'border-box',
background: '#ffffff'
}}
/>
</div> </div>
</div> </div>
)} )}
{typeParam === 'classiques' && (
<div style={{ background: 'var(--wcs-light)', padding: '16px', borderRadius: '4px', marginBottom: '16px', flexGrow: 1 }}>
<p style={{ fontWeight: 'bold', marginBottom: '12px' }}>Options d'affichage :</p>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '14px', marginBottom: '8px' }}>Lignes étendues :</label>
<div style={{ display: 'flex', gap: '8px' }}>
{[0, 1, 2, 3].map(num => (
<WcsButton
key={num}
mode={extendedLines === num ? 'solid' : 'stroked'}
size="s"
onClick={() => setExtendedLines(num)}
>
{num === 0 ? '0' : num}
</WcsButton>
))}
</div>
</div>
<WcsCheckbox checked={showTime} onWcsChange={(e) => setShowTime(e.detail.checked)}><strong>Afficher l'heure & infos</strong></WcsCheckbox>
</div>
)}
<WcsButton className="wcs-primary" style={{ width: '100%', marginTop: 'auto' }} onClick={handleGeneral}>
Lancer l'Afficheur Général
</WcsButton>
</WcsCardBody>
</WcsCard>
<WcsCard mode="flat" style={{ border: '1px solid var(--wcs-light)' }}>
<WcsCardBody style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '24px', marginRight: '8px' }}>🚉</span>
<WcsBadge className="wcs-warning">Écran de Voie</WcsBadge>
</div>
<h2 style={{ fontSize: '20px', margin: '0 0 8px 0' }}>Afficheur par Quai</h2>
<p style={{ fontSize: '14px', color: 'var(--wcs-text-light)', marginBottom: '16px' }}>
Affiche le prochain train au départ/arrivée avec la liste déroulante des gares desservies sur la voie.
</p>
<div style={{ background: 'var(--wcs-light)', padding: '16px', borderRadius: '4px', marginBottom: '16px', flexGrow: 1 }}>
{loading ? (
<p style={{ fontWeight: 'bold', color: 'var(--wcs-primary)' }}>⏳ Détection des voies...</p>
) : (
<>
{platforms.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontWeight: 'bold', marginBottom: '8px', fontSize: '14px' }}>Voies actives détectées :</label>
<WcsSelect
value={selectedPlatform}
onWcsChange={(e) => {
setSelectedPlatform(e.detail.value);
setManualPlatform('');
}}
>
{platforms.map(p => (
<WcsSelectOption key={p} value={p}>Voie {p}</WcsSelectOption>
))}
</WcsSelect>
</div>
)}
<div>
<label style={{ display: 'block', fontWeight: 'bold', marginBottom: '8px', fontSize: '14px' }}>Saisie libre d'une voie :</label>
<WcsInput
placeholder="Ex: A, B, 1, 2 bis..."
value={manualPlatform}
onWcsInput={(e) => setManualPlatform(e.target.value)}
/>
</div>
</>
)}
</div>
<WcsButton className="wcs-success" style={{ width: '100%', marginTop: 'auto' }} onClick={handlePlatform}>
Lancer l'Afficheur de Quai
</WcsButton>
</WcsCardBody>
</WcsCard>
{typeParam === 'classiques' && (
<>
<WcsCard mode="flat" style={{ border: '1px solid var(--wcs-light)' }}>
<WcsCardBody style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '24px', marginRight: '8px' }}>⚡</span>
<WcsBadge className="wcs-warning">Flash Info Trafic</WcsBadge>
</div>
<h2 style={{ fontSize: '20px', margin: '0 0 8px 0' }}>Flash Information</h2>
<p style={{ fontSize: '14px', color: 'var(--wcs-text-light)', marginBottom: '16px', flexGrow: 1 }}>
Écran dédié aux perturbations de circulation en temps réel et travaux de maintenance sur le réseau.
</p>
<WcsButton className="wcs-warning" style={{ width: '100%', marginTop: 'auto' }} onClick={() => {
const kebab = gareParam ? String(gareParam).trim() : '';
navigate(`${prefix}/afficheurs/classiques/flash-info?gare=${encodeURIComponent(kebab)}`);
}}>
Lancer le Flash Information
</WcsButton>
</WcsCardBody>
</WcsCard>
<WcsCard mode="flat" style={{ border: '1px solid var(--wcs-light)' }}>
<WcsCardBody style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '24px', marginRight: '8px' }}>ℹ️</span>
<WcsBadge className="wcs-info">Messages Gare</WcsBadge>
</div>
<h2 style={{ fontSize: '20px', margin: '0 0 8px 0' }}>Informations Voyageurs</h2>
<p style={{ fontSize: '14px', color: 'var(--wcs-text-light)', marginBottom: '16px', flexGrow: 1 }}>
Écran d'annonces et consignes dynamiques de la gare (Vigipirate, étiquetage des bagages, sécurité).
</p>
<WcsButton className="wcs-info" style={{ width: '100%', marginTop: 'auto' }} onClick={() => {
const kebab = gareParam ? String(gareParam).trim() : '';
navigate(`${prefix}/afficheurs/classiques/informations?gare=${encodeURIComponent(kebab)}`);
}}>
Lancer l'Afficheur Informations
</WcsButton>
</WcsCardBody>
</WcsCard>
</>
)}
</div>
{typeParam === 'afl' && (
<div style={{ borderTop: '1px solid var(--wcs-light)', paddingTop: '24px', marginBottom: '24px' }}>
<p style={{ fontWeight: 'bold', marginBottom: '12px' }}>Accès direct à une page fixe AFL :</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<WcsButton mode="stroked" size="s" onClick={() => handleSinglePage('1')}>Page 1</WcsButton>
<WcsButton mode="stroked" size="s" onClick={() => handleSinglePage('2')}>Page 2</WcsButton>
<WcsButton mode="stroked" size="s" onClick={() => handleSinglePage('infos')}>Informations</WcsButton>
<WcsButton mode="stroked" size="s" onClick={() => handleSinglePage('securite1')}>Sécurité (Bagages)</WcsButton>
<WcsButton mode="stroked" size="s" onClick={() => handleSinglePage('securite2')}>Sécurité (Vigipirate)</WcsButton>
<WcsButton mode="stroked" size="s" onClick={() => handleSinglePage('corresp')}>Correspondances</WcsButton>
</div> </div>
</div> </div>
)}
<button <div style={{ borderTop: '1px solid var(--wcs-light)', paddingTop: '16px' }}>
type="button" <WcsButton mode="clear" onClick={() => {
onClick={handlePlatform}
className="launcher-btn launcher-btn--success"
>
🚉 Lancer l'Afficheur de Quai
</button>
</div>
</div>
{/* Accès direct aux pages fixes AFL */}
{typeParam === 'afl' && (
<div style={{ borderTop: '1px solid #e2ebf6', paddingTop: '20px', marginBottom: '20px' }}>
<span className="launcher-chips-label">Accès direct à une page fixe AFL :</span>
<div className="launcher-chips-list">
<button type="button" onClick={() => handleSinglePage('1')} className="launcher-chip">
Page 1 (Horaires)
</button>
<button type="button" onClick={() => handleSinglePage('2')} className="launcher-chip">
Page 2 (Horaires)
</button>
<button type="button" onClick={() => handleSinglePage('infos')} className="launcher-chip">
Informations Trafic
</button>
<button type="button" onClick={() => handleSinglePage('securite1')} className="launcher-chip">
Sécurité (Bagages)
</button>
<button type="button" onClick={() => handleSinglePage('securite2')} className="launcher-chip">
Sécurité (Vigipirate / 3117)
</button>
<button type="button" onClick={() => handleSinglePage('corresp')} className="launcher-chip">
Correspondances
</button>
</div>
</div>
)}
{/* Navigation retour */}
<div className="launcher-footer-nav">
<WcsButton
mode="plain"
onClick={() => {
if (typeof onBack === 'function') { if (typeof onBack === 'function') {
onBack(); onBack();
} else if (typeof navigate === 'function') { } else if (typeof navigate === 'function') {
navigate(`${prefix}/afficheurs?gare=${encodeURIComponent(gareParam)}&mode=${modeParam}`); navigate(`${prefix}/afficheurs?gare=${encodeURIComponent(gareParam)}&mode=${modeParam}`);
} }
}} }}>
label="← Retour au choix du style" ← Retour au choix du style
/> </WcsButton>
</div> </div>
</div> </WcsCardBody>
</WcsCard>
</div> </div>
); );
} }
+346 -53
View File
@@ -1,3 +1,4 @@
/* eslint-disable */
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import './AFLArrivees.css'; import './AFLArrivees.css';
import supabase from '../../../lib/supabase'; import supabase from '../../../lib/supabase';
@@ -6,7 +7,8 @@ import MarqueeText from '../../../components/MarqueeText';
import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; 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, resolveNominalTrack } from '../../../services/sncfApi';
import { matchStationNameStrict } from '../../../services/gtfsStations';
import AFLSecurityPage from './components/AFLSecurityPage'; import AFLSecurityPage from './components/AFLSecurityPage';
import AFLCorrespondancesPage from './components/AFLCorrespondancesPage'; import AFLCorrespondancesPage from './components/AFLCorrespondancesPage';
import AFLInformationsPage from './components/AFLInformationsPage'; import AFLInformationsPage from './components/AFLInformationsPage';
@@ -53,6 +55,215 @@ const BusIcon = ({ size = '59px', color = 'white', style = {} }) => (
</svg> </svg>
); );
// Icône Débarquement (Arrivées AFL)
const AFLDebarquementIcon = ({ size = '49px', color = 'white', style = {} }) => (
<svg
viewBox="0 0 32 32"
width={size}
height={size}
fill="none"
style={{ flexShrink: 0, ...style }}
>
{/* Voiture de train / Porte à gauche */}
<path d="M10 4v24M10 4H4M10 28H4" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10 25h3" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<path d="M13 28h3" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
{/* Silhouette du passager descendant du train vers le quai (tourné vers la droite) */}
<circle cx="20.5" cy="8.5" r="2.5" fill={color} />
<path
d="M19 13h4l1.5 7-2.5 1-1-4-2 7-3-1 1.5-10z"
fill={color}
/>
<path d="M17 15l-3 3" stroke={color} strokeWidth="2" strokeLinecap="round" />
</svg>
);
// Pastilles de modification de parcours
const AFLRouteBadge = ({ type = 'modified', size = '52px', style = {} }) => {
if (type === 'cancelled' || type === 'suppressed') {
return (
<svg
viewBox="0 0 36 36"
width={size}
height={size}
style={{ flexShrink: 0, marginRight: '14px', display: 'inline-block', verticalAlign: 'middle', ...style }}
>
<circle cx="18" cy="18" r="18" fill="#c8102e" />
<path d="M11 11L25 25M25 11L11 25" stroke="#ffffff" strokeWidth="4.2" strokeLinecap="round" />
</svg>
);
}
if (type === 'added') {
return (
<svg
viewBox="0 0 36 36"
width={size}
height={size}
style={{ flexShrink: 0, marginRight: '14px', display: 'inline-block', verticalAlign: 'middle', ...style }}
>
<circle cx="18" cy="18" r="18" fill="#0084ff" />
<path d="M18 9v18M9 18h18" stroke="#ffffff" strokeWidth="4.2" strokeLinecap="round" />
</svg>
);
}
return (
<svg
viewBox="0 0 36 36"
width={size}
height={size}
style={{ flexShrink: 0, marginRight: '14px', display: 'inline-block', verticalAlign: 'middle', ...style }}
>
<circle cx="18" cy="18" r="18" fill="#38d9f5" />
<rect x="15.8" y="8" width="4.4" height="12.5" rx="2.2" fill="#002d62" />
<circle cx="18" cy="26" r="2.5" fill="#002d62" />
</svg>
);
};
// Helper détection des modifications de parcours
const detectRouteModification = (horaire, allPerturbs = [], isDeparts = false, normalizeName = (s) => s) => {
if (!horaire) return { destModif: null, perturbStopsMap: new Map() };
const trainNo = String(horaire.numero_train || horaire.number || horaire.numero || '').trim();
const horaireId = String(horaire.id || '').trim();
const matchingPerturbs = (allPerturbs || []).filter(p => {
if (!p) return false;
const pRef = String(p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero || '').trim();
const pNum = String(p.number || p.train_number || p.numero || p.numero_train || '').trim();
if (horaireId && pRef === horaireId) return true;
if (trainNo && (pRef === trainNo || pNum === trainNo)) return true;
return false;
});
const perturbStopsMap = new Map();
let explicitNewDest = null;
let explicitOldDest = null;
let explicitNewOrigin = null;
let explicitOldOrigin = null;
matchingPerturbs.forEach(p => {
if (p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee) {
explicitNewDest = p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee;
}
if (p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale) {
explicitOldDest = p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale;
}
if (p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee) {
explicitNewOrigin = p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee;
}
if (p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale) {
explicitOldOrigin = p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale;
}
const rawArrets = p.perturbations_arrets || p.arrets || p.arrets_perturbations;
let arretsList = [];
if (Array.isArray(rawArrets)) {
arretsList = rawArrets;
} else if (rawArrets && typeof rawArrets === 'object' && Array.isArray(rawArrets.arrets)) {
arretsList = rawArrets.arrets;
} else if (typeof rawArrets === 'string') {
try {
const parsed = JSON.parse(rawArrets);
arretsList = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.arrets) ? parsed.arrets : []);
} catch (e) {}
}
arretsList.forEach(a => {
const aName = a.gare_nom || a.nom || a.name || a.station_name || (typeof a === 'string' ? a : '');
if (!aName) return;
const aType = String(a.type_perturbation || a.type || a.status || '').toLowerCase();
const norm = normalizeName(aName);
perturbStopsMap.set(norm, {
name: aName,
type: aType,
isSupprime: aType.includes('suppr') || aType.includes('annul') || aType.includes('canc'),
isAjoute: aType.includes('ajout') || aType.includes('add') || aType.includes('nouv'),
isModifie: aType.includes('modif')
});
});
});
if (horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination) {
explicitOldDest = horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination;
}
if (horaire.nouvelle_destination || horaire.nouveau_terminus || (horaire.destination_modifiee && horaire.gare_arrivee_nom)) {
explicitNewDest = horaire.nouvelle_destination || horaire.nouveau_terminus || horaire.gare_arrivee_nom;
}
if (horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine) {
explicitOldOrigin = horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine;
}
if (horaire.nouvelle_origine || (horaire.origine_modifiee && horaire.gare_depart_nom)) {
explicitNewOrigin = horaire.nouvelle_origine || horaire.gare_depart_nom;
}
let destModif = null;
if (isDeparts) {
const currentDest = horaire.gare_arrivee_nom || horaire.destination || '';
const normDest = normalizeName(currentDest);
const destInPerturb = perturbStopsMap.get(normDest);
if (explicitNewDest && explicitOldDest && normalizeName(explicitNewDest) !== normalizeName(explicitOldDest)) {
destModif = {
newStation: explicitNewDest,
oldStation: explicitOldDest,
isAdded: Boolean(destInPerturb?.isAjoute || /ajout/.test(String(horaire.type_modification || '')))
};
} else if (destInPerturb?.isSupprime) {
destModif = {
newStation: explicitNewDest || 'Terminus modifié',
oldStation: currentDest,
isAdded: false
};
} else if (destInPerturb?.isAjoute) {
destModif = {
newStation: currentDest,
oldStation: explicitOldDest || 'Destination modifiée',
isAdded: true
};
} else if (explicitOldDest && normalizeName(explicitOldDest) !== normDest) {
destModif = {
newStation: currentDest,
oldStation: explicitOldDest,
isAdded: Boolean(horaire.destination_ajoutee)
};
}
} else {
const currentOrig = horaire.gare_depart_nom || horaire.origin || '';
const normOrig = normalizeName(currentOrig);
const origInPerturb = perturbStopsMap.get(normOrig);
if (explicitNewOrigin && explicitOldOrigin && normalizeName(explicitNewOrigin) !== normalizeName(explicitOldOrigin)) {
destModif = {
newStation: explicitNewOrigin,
oldStation: explicitOldOrigin,
isAdded: Boolean(origInPerturb?.isAjoute)
};
} else if (origInPerturb?.isSupprime) {
destModif = {
newStation: explicitNewOrigin || 'Origine modifiée',
oldStation: currentOrig,
isAdded: false
};
} else if (origInPerturb?.isAjoute) {
destModif = {
newStation: currentOrig,
oldStation: explicitOldOrigin || 'Origine initiale',
isAdded: true
};
} else if (explicitOldOrigin && normalizeName(explicitOldOrigin) !== normOrig) {
destModif = {
newStation: currentOrig,
oldStation: explicitOldOrigin,
isAdded: Boolean(horaire.origine_ajoutee)
};
}
}
return { destModif, perturbStopsMap };
};
const ViasMarquee = ({ viaNames }) => { const ViasMarquee = ({ viaNames }) => {
const containerRef = useRef(null); const containerRef = useRef(null);
const textRef = useRef(null); const textRef = useRef(null);
@@ -66,7 +277,6 @@ const ViasMarquee = ({ viaNames }) => {
const tWidth = textRef.current.scrollWidth; const tWidth = textRef.current.scrollWidth;
if (tWidth > cWidth) { if (tWidth > cWidth) {
setIsOverflowing(true); setIsOverflowing(true);
// Vitesse de défilement rapide (~280px/s)
setDuration(Math.max(1.0, tWidth / 280)); setDuration(Math.max(1.0, tWidth / 280));
} else { } else {
setIsOverflowing(false); setIsOverflowing(false);
@@ -97,18 +307,38 @@ const ViasMarquee = ({ viaNames }) => {
animationDuration: isOverflowing ? `${duration}s` : undefined animationDuration: isOverflowing ? `${duration}s` : undefined
}} }}
> >
{viaNames.map((v, i) => ( {viaNames.map((item, i) => {
<React.Fragment key={i}> const name = typeof item === 'string' ? item : (item.name || item.gare_nom || '');
{i > 0 && <span className="afl-arr-vias-chevron">&gt;</span>} const isSupprime = typeof item === 'object' && Boolean(item.isSupprime);
<span className="afl-arr-vias-stop">{v}</span> const isAjoute = typeof item === 'object' && Boolean(item.isAjoute);
</React.Fragment> const isModifie = typeof item === 'object' && Boolean(item.isModifie);
))}
return (
<React.Fragment key={i}>
{i > 0 && <span className="afl-arr-vias-chevron">&gt;</span>}
<span
className="afl-arr-vias-stop"
style={{
display: 'inline-flex',
alignItems: 'center',
...(isSupprime ? { textDecoration: 'line-through', opacity: 0.85 } : {})
}}
>
{isSupprime && <AFLRouteBadge type="cancelled" size="26px" style={{ marginRight: '6px' }} />}
{isAjoute && <AFLRouteBadge type="added" size="26px" style={{ marginRight: '6px' }} />}
{isModifie && <AFLRouteBadge type="modified" size="26px" style={{ marginRight: '6px' }} />}
<span>{name}</span>
</span>
</React.Fragment>
);
})}
</div> </div>
</div> </div>
</div> </div>
); );
}; };
function Clock(){ function Clock(){
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
useEffect(() => { useEffect(() => {
@@ -219,12 +449,8 @@ export default function AFLArrivees(){
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle);
if (!gerr && gdata && gdata.length) { if (!gerr && gdata && gdata.length) {
const exactMatch = gdata.find(g => { const exactMatch = gdata.find(g => matchStationNameStrict(g.nom, gareDisplay));
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); const chosen = exactMatch || (gdata.length === 1 ? gdata[0] : null);
const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
return n1 === n2;
});
const chosen = exactMatch || gdata[0];
fetchedType = chosen ? chosen.type : null; fetchedType = chosen ? chosen.type : null;
fetchedId = chosen ? chosen.id : null; fetchedId = chosen ? chosen.id : null;
} }
@@ -631,7 +857,12 @@ export default function AFLArrivees(){
} }
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; if (maxDelay != null) {
const dH = Math.floor(maxDelay / 60);
const dRem = maxDelay % 60;
const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`;
return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay };
}
return { label: "à l'heure", detail: '', delayMinutes: null }; return { label: "à l'heure", detail: '', delayMinutes: null };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
@@ -662,17 +893,18 @@ export default function AFLArrivees(){
const stopForStation = (stops || []).find(s => { const stopForStation = (stops || []).find(s => {
const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';
const ns = normalizeName(nm || ''); return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && matchStationNameStrict(nm, targetStationName));
const target = normalizeName(targetStationName || '');
return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target);
}) || null; }) || null;
horaire._status = displayStatus; horaire._status = displayStatus;
horaire._displayTime = displayTime; horaire._displayTime = displayTime;
const platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || ''; let platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform || stopForStation.voie)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || horaire.voie || '';
horaire._displayPlatform = String(platformVal).trim(); if (!platformVal) {
platformVal = resolveNominalTrack(targetStationName || gareDisplay, horaire.numero_train || horaire.num, targetStationId || gareIdParam);
}
horaire._displayPlatform = String(platformVal || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
return horaire; return horaire;
}); });
}, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, gareParam, getStops, normalizeName]);
// Récupération des données (SNCF GTFS ou Supabase) — avec polling pour synchroniser en continu // Récupération des données (SNCF GTFS ou Supabase) — avec polling pour synchroniser en continu
useEffect(() => { useEffect(() => {
@@ -699,11 +931,13 @@ export default function AFLArrivees(){
} else { } else {
const formatted = (sncfData || []).map(t => { const formatted = (sncfData || []).map(t => {
const timeStr = t.heure_arrivee || t.heure_depart || t.time || ''; const timeStr = t.heure_arrivee || t.heure_depart || t.time || '';
const arrHM = (typeof t._arrHM === 'number') ? t._arrHM : parseTimeHM(timeStr);
const plat = String(t._displayPlatform || t.quai_reel || t.quai || t.voie || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
return { return {
...t, ...t,
_displayTime: formatTimeNoSeconds(timeStr), _displayTime: formatTimeNoSeconds(timeStr),
_displayPlatform: t.quai || t.voie || '', _displayPlatform: plat,
_arrHM: (typeof t._arrHM === 'number') ? t._arrHM : parseTimeHM(timeStr), _arrHM: arrHM,
_status: t._status || { _status: t._status || {
label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"), label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"),
detail: '', detail: '',
@@ -784,25 +1018,24 @@ export default function AFLArrivees(){
// comme une gare d'arrivée (soit le terminus, soit un arrêt intermédiaire) // comme une gare d'arrivée (soit le terminus, soit un arrêt intermédiaire)
let displayHoraires = parsedHoraires.slice(); let displayHoraires = parsedHoraires.slice();
if (gareDisplay) { if (gareDisplay) {
const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));
displayHoraires = displayHoraires.filter(h => { displayHoraires = displayHoraires.filter(h => {
// Exclure si la gare ciblée est la gare de départ (on veut les arrivées, pas les départs) // Exclure si la gare ciblée est la gare de départ (on veut les arrivées, pas les départs)
const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); const departName = (h.gare_depart_nom || h.gare_depart || '').toString();
if (departName === normalized) return false; if (matchStationNameStrict(departName, gareDisplay)) return false;
// Inclure si gare d'arrivée finale correspond // Inclure si gare d'arrivée finale correspond
const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); const arriveeName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString();
if (arriveeName === normalized) return true; if (matchStationNameStrict(arriveeName, gareDisplay)) return true;
// Chercher dans les arrêts desservis ; ignorer si l'arrêt est l'origine (isFirst ou index==0) // Chercher dans les arrêts desservis ; ignorer si l'arrêt est l'origine (isFirst ou index==0)
const stopsList = getStops(h); const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) { if (Array.isArray(stopsList) && stopsList.length) {
for (const s of stopsList) { for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); const stopName = (s.gare_nom || s.nom || s.name || s.stop_name || '').toString();
if (!stopName) continue; if (!stopName) continue;
const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0)); const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
if (isOrigin) continue; // Si c'est l'origine, ça ne peut pas être une arrivée if (isOrigin) continue; // Si c'est l'origine, ça ne peut pas être une arrivée
if (stopName === normalized) return true; if (matchStationNameStrict(stopName, gareDisplay)) return true;
} }
} }
return false; return false;
@@ -868,7 +1101,7 @@ export default function AFLArrivees(){
const stopsListForH = getStops(h); const stopsListForH = getStops(h);
if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) { if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) {
const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null; const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;
const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; const byName = gareDisplay ? stopsListForH.find(s => matchStationNameStrict(s.gare_nom || s.nom || s.name || '', gareDisplay)) : null;
const stop = byId || byName; const stop = byId || byName;
if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure; if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure;
} }
@@ -1137,7 +1370,7 @@ export default function AFLArrivees(){
const intervalId = setInterval(fetchData, intervalMs); const intervalId = setInterval(fetchData, intervalMs);
return () => { mounted = false; clearInterval(intervalId); }; return () => { mounted = false; clearInterval(intervalId); };
}, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]); }, [gareDisplay, gareParam, gareIdParam]);
// Helpers pour perturbations (memoized plus haut) // Helpers pour perturbations (memoized plus haut)
@@ -1249,16 +1482,7 @@ export default function AFLArrivees(){
{/* HEADER */} {/* HEADER */}
<header className="afl-arr-header"> <header className="afl-arr-header">
<div className="afl-arr-header-left"> <div className="afl-arr-header-left">
<svg width="49px" height="49px" viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}> <AFLDebarquementIcon size="49px" color="white" />
{/* Porte de train verticale */}
<path d="M5 4v20M5 4h3M5 24h3" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
{/* Silhouette tête */}
<circle cx="10.5" cy="7" r="2.2" fill="white" />
{/* Silhouette corps + marche vers la droite */}
<path d="M8.5 11h4l1.5 5.5-2 1-1-3.5-1 5.5-3-1 2-7.5z" fill="white" />
{/* Chevrons >> */}
<path d="M20 8l4 6-4 6M15 8l4 6-4 6" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div className="afl-arr-header-title">Arrivées</div> <div className="afl-arr-header-title">Arrivées</div>
</div> </div>
@@ -1300,7 +1524,17 @@ export default function AFLArrivees(){
).toString().trim(); ).toString().trim();
const isSub = (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero)); const isSub = (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero));
const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR'); const isCarTER = (
op === 'CAR TER' ||
op === 'AUTOCAR' ||
op.includes('AUTOCAR') ||
op.includes('CAR') ||
op.includes('BUS') ||
rawPlatform === 'AUTOCAR' ||
rawPlatform === 'CAR' ||
rawPlatform === 'BUS' ||
isSub
);
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';
@@ -1316,20 +1550,34 @@ export default function AFLArrivees(){
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';
let delayVal = '+ 5 min'; let delayVal = '+ 5 min';
const formatDelayVal = (m) => {
if (m >= 60) {
const h = Math.floor(m / 60);
const rem = m % 60;
return rem > 0 ? `+ ${h}h${String(rem).padStart(2, '0')}` : `+ ${h}h`;
}
return `+ ${m} min`;
};
if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) {
delayVal = `+ ${status.delayMinutes} min`; delayVal = formatDelayVal(status.delayMinutes);
} else if (status?.delayMinutes) { } else if (status?.delayMinutes) {
delayVal = `+ ${status.delayMinutes} min`; const parsed = parseInt(status.delayMinutes, 10);
delayVal = !isNaN(parsed) && parsed > 0 ? formatDelayVal(parsed) : `+ ${status.delayMinutes} min`;
} else if (status?.label && status.label.includes('+')) { } else if (status?.label && status.label.includes('+')) {
delayVal = status.label; const m = status.label.match(/\+(\d+)\s*(?:min)?/);
if (m) delayVal = formatDelayVal(parseInt(m[1], 10));
else delayVal = status.label;
} }
let delayDisplayText = alternateDelay ? 'retardé' : delayVal; let delayDisplayText = alternateDelay ? 'retardé' : delayVal;
let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure'; let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure';
// Calcul des vias (gares entre l'origine et la gare d'arrivée) // Détection des modifications de parcours (Origine modifiée)
const { destModif: originModif, perturbStopsMap } = detectRouteModification(horaire, perturbations, false, normalizeName);
// Calcul des gares de provenance (gares desservies avant la gare affichée dans l'ordre décroissant)
const stopsList = getStops(horaire); const stopsList = getStops(horaire);
const viaNames = []; const rawPrevStops = [];
if (Array.isArray(stopsList)) { if (Array.isArray(stopsList)) {
for (const s of stopsList) { for (const s of stopsList) {
const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; const sName = s.gare_nom || s.nom || s.name || s.station_name || '';
@@ -1337,9 +1585,26 @@ export default function AFLArrivees(){
const norm = normalizeName(sName); const norm = normalizeName(sName);
if (norm === normalizeName(origin || '')) continue; if (norm === normalizeName(origin || '')) continue;
if (norm === normalizeName(stationName || '')) break; if (norm === normalizeName(stationName || '')) break;
viaNames.push(sName); rawPrevStops.push(s);
} }
} }
// Ordre décroissant : la gare la plus proche avant l'arrivée en premier
rawPrevStops.reverse();
const viaItems = rawPrevStops.map(s => {
const sName = s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : '');
const norm = normalizeName(sName);
const pInfo = perturbStopsMap.get(norm);
const isSupprime = Boolean(pInfo?.isSupprime || s.is_supprime || s.supprime || s.type === 'arret_supprime');
const isAjoute = Boolean(pInfo?.isAjoute || s.is_ajoute || s.ajoute || s.type === 'arret_ajoute');
const isModifie = Boolean(pInfo?.isModifie || s.is_modifie || s.modifie);
return {
name: sName,
isSupprime,
isAjoute,
isModifie
};
});
const renderPillIcon = () => { const renderPillIcon = () => {
if (isSuppressed) { if (isSuppressed) {
@@ -1422,16 +1687,44 @@ export default function AFLArrivees(){
<> <>
<div className="afl-arr-top-row"> <div className="afl-arr-top-row">
{isCarTER && <BusIcon size="52px" color="white" style={{ marginRight: '15px' }} />} {isCarTER && <BusIcon size="52px" color="white" style={{ marginRight: '15px' }} />}
<MarqueeText text={origin} className="afl-arr-text-dest" style={{ flex: 'none' }} /> {originModif ? (
alternateDelay ? (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type={originModif.isAdded ? 'added' : 'modified'} size="52px" />
<MarqueeText text={originModif.newStation} className="afl-arr-text-dest" style={{ flex: 'none' }} />
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type="cancelled" size="52px" />
<MarqueeText text={originModif.oldStation} className="afl-arr-text-dest" style={{ flex: 'none' }} />
</div>
)
) : (
<MarqueeText text={origin} className="afl-arr-text-dest" style={{ flex: 'none' }} />
)}
</div> </div>
<div className="afl-arr-bottom-row"> <div className="afl-arr-bottom-row">
<ViasMarquee viaNames={viaNames} /> <ViasMarquee viaNames={viaItems} />
</div> </div>
</> </>
) : ( ) : (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}> <div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
{isCarTER && <BusIcon size="49px" color="white" style={{ marginRight: '15px' }} />} {isCarTER && <BusIcon size="49px" color="white" style={{ marginRight: '15px' }} />}
<MarqueeText text={origin} className="afl-arr-text-dest" style={{ flex: 'none' }} /> {originModif ? (
alternateDelay ? (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type={originModif.isAdded ? 'added' : 'modified'} size="46px" />
<MarqueeText text={originModif.newStation} className="afl-arr-text-dest" style={{ flex: 'none' }} />
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type="cancelled" size="46px" />
<MarqueeText text={originModif.oldStation} className="afl-arr-text-dest" style={{ flex: 'none' }} />
</div>
)
) : (
<MarqueeText text={origin} className="afl-arr-text-dest" style={{ flex: 'none' }} />
)}
</div> </div>
)} )}
</div> </div>
+83 -29
View File
@@ -6,6 +6,11 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
import Logo from '../../../components/Logo'; import Logo from '../../../components/Logo';
import { getSourceFromPath } from '../../../services/railwayService'; import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi'; import { getSncfTimetable } from '../../../services/sncfApi';
import { matchStationNameStrict } from '../../../services/gtfsStations';
import GareVideoprotection from '../../../components/afficheurs/GareVideoprotection';
import AttentifsEnsemble from '../../../components/afficheurs/AttentifsEnsemble';
import AFLInformationsPage from './components/AFLInformationsPage';
import AFLArrivees from './AFLArrivees';
// Helper utilitaire : format HH:MM sans secondes (ex: 15:57) // Helper utilitaire : format HH:MM sans secondes (ex: 15:57)
const formatTimeNoSeconds = (v) => { const formatTimeNoSeconds = (v) => {
@@ -28,12 +33,16 @@ const AFLArriveesQuais = () => {
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const [nextArrival, setNextArrival] = useState(null); const [nextArrival, setNextArrival] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [idlePhase, setIdlePhase] = useState(0); // 0 = Attentifs (20s), 1 = Quai (20s), 2 = Information (10s)
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const gareParam = searchParams.get('gare') || ''; const gareParam = searchParams.get('gare') || '';
const quaiParam = searchParams.get('quai') || searchParams.get('voie') || ''; const quaiParam = searchParams.get('quai') || searchParams.get('voie') || '';
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;
const pageParam = (searchParams.get('page') || searchParams.get('mode') || '').toLowerCase();
const isVideoprotection = pageParam === 'videoprotection' || searchParams.get('videoprotection') === 'true';
useAnnonceSonoreRealtime(null, 'AFLArriveesQuais', quaiParam); useAnnonceSonoreRealtime(null, 'AFLArriveesQuais', quaiParam);
useEffect(() => { useEffect(() => {
@@ -365,22 +374,36 @@ const AFLArriveesQuais = () => {
quai: quaiParam quai: quaiParam
}); });
if (sncfData && sncfData.length > 0) { if (sncfData && sncfData.length > 0) {
const train = sncfData[0]; let matching = sncfData;
const enriched = { if (quaiParam) {
...train, const cleanP = (p) => String(p || '').toLowerCase().replace(/^(?:voie|quai|track|platform)\s*/i, '').replace(/\s+/g, '').trim();
_rawTime: train.heure_arrivee || train.heure_depart, matching = sncfData.filter(t => cleanP(t._displayPlatform || t.quai || t.voie) === cleanP(quaiParam));
_displayTime: formatTimeNoSeconds(train.heure_arrivee || train.heure_depart), }
_status: { const train = matching[0];
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure"), if (train) {
detail: '', const trainPlatform = train._displayPlatform || train.quai || train.voie || quaiParam || '';
delayMinutes: train.retard_minutes || null const enriched = {
}, ...train,
_nextStationStatus: { quai: trainPlatform,
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure") quai_reel: trainPlatform,
}, voie: trainPlatform,
_isToday: true _displayPlatform: trainPlatform,
}; _rawTime: train.heure_arrivee || train.heure_depart,
setNextArrival(enriched); _displayTime: formatTimeNoSeconds(train.heure_arrivee || train.heure_depart),
_status: {
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure"),
detail: '',
delayMinutes: train.retard_minutes || null
},
_nextStationStatus: {
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure")
},
_isToday: true
};
setNextArrival(enriched);
} else {
setNextArrival(null);
}
} else { } else {
setNextArrival(null); setNextArrival(null);
} }
@@ -583,25 +606,24 @@ const AFLArriveesQuais = () => {
// Filter by station: keep arrivals to this station or stops at this station (excluding origin) // Filter by station: keep arrivals to this station or stops at this station (excluding origin)
let stationFiltered = enriched; let stationFiltered = enriched;
if (gareDisplay) { if (gareDisplay) {
const normalized = normalizeName(gareDisplay);
stationFiltered = enriched.filter(h => { stationFiltered = enriched.filter(h => {
// Exclude if target station is the departure origin // Exclude if target station is the departure origin
const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); const departName = (h.gare_depart_nom || h.gare_depart || '').toString();
if (departName === normalized) return false; if (matchStationNameStrict(departName, gareDisplay)) return false;
// Include if target station is the terminus // Include if target station is the terminus
const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); const arriveeName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString();
if (arriveeName === normalized) return true; if (matchStationNameStrict(arriveeName, gareDisplay)) return true;
// Check intermediate stops // Check intermediate stops
const stopsList = getStops(h); const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) { if (Array.isArray(stopsList) && stopsList.length) {
for (const s of stopsList) { for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || '').toString()); const stopName = (s.gare_nom || s.nom || s.name || '').toString();
if (!stopName) continue; if (!stopName) continue;
const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0)); const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0));
if (isOrigin) continue; if (isOrigin) continue;
if (stopName === normalized) return true; if (matchStationNameStrict(stopName, gareDisplay)) return true;
} }
} }
return false; return false;
@@ -668,8 +690,10 @@ const AFLArriveesQuais = () => {
if (gareDisplay && Array.isArray(stopsList)) { if (gareDisplay && Array.isArray(stopsList)) {
stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay));
} }
const platform = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || h.quai_reel || h.voie || h.quai || '').toString().trim(); const platform = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay || stopForStation.voie)) || h.quai_reel || h.voie || h.quai || '').toString().trim();
return quaiParam && platform.toLowerCase() === quaiParam.toLowerCase(); if (!quaiParam) return true;
const cleanP = (p) => String(p || '').toLowerCase().replace(/^(?:voie|quai|track|platform)\s*/i, '').replace(/\s+/g, '').trim();
return cleanP(platform) === cleanP(quaiParam);
}); });
if (candidatesOnPlatform.length > 0) { if (candidatesOnPlatform.length > 0) {
@@ -684,13 +708,44 @@ const AFLArriveesQuais = () => {
} }
}, [applyPerturbationsToHoraires, buildPerturbMap, gareDisplay, gareParam, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]); }, [applyPerturbationsToHoraires, buildPerturbMap, gareDisplay, gareParam, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]);
// Alternance par défaut (20s Attentifs -> 20s Quai -> 10s Information) si aucun train n'est prévu sur le quai
useEffect(() => {
if (nextArrival) return;
let duration = 20000;
if (idlePhase === 2) duration = 10000;
else duration = 20000;
const timer = setTimeout(() => {
setIdlePhase(prev => (prev + 1) % 3);
}, duration);
return () => clearTimeout(timer);
}, [idlePhase, nextArrival]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const interval = setInterval(fetchData, 15000); const interval = setInterval(fetchData, 15000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchData]); }, [fetchData]);
if (loading) return <div className="afl-q-arr-container" style={{ background: '#173e31' }}></div>; // AFFICHAGE 1 : Page Vidéoprotection (via query)
if (isVideoprotection) {
return <GareVideoprotection />;
}
// AFFICHAGE 2 : Alternance si aucun train n'est attribué à ce quai
if (!nextArrival && gareParam) {
if (idlePhase === 0) {
return <AttentifsEnsemble />;
}
if (idlePhase === 1) {
return <AFLArrivees />;
}
if (idlePhase === 2) {
return <AFLInformationsPage mode="arrivees" gareName={gareDisplay || 'Votre Gare'} />;
}
}
if (loading && !nextArrival) return <div className="afl-q-arr-container" style={{ background: '#173e31' }}></div>;
if (!nextArrival) { if (!nextArrival) {
return ( return (
@@ -821,10 +876,9 @@ const AFLArriveesQuais = () => {
let journeyStops = []; let journeyStops = [];
if (Array.isArray(stopsList) && stopsList.length > 0) { if (Array.isArray(stopsList) && stopsList.length > 0) {
const normCurrent = normalizeName(currentStationName);
const endIdx = stopsList.findIndex(s => { const endIdx = stopsList.findIndex(s => {
const sName = normalizeName((s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : '')).toString()); const sName = (s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : '')).toString();
return sName && (sName === normCurrent || sName.includes(normCurrent) || normCurrent.includes(sName)); return sName && matchStationNameStrict(sName, currentStationName);
}); });
const rawStops = endIdx !== -1 ? stopsList.slice(0, endIdx + 1) : stopsList.slice(); const rawStops = endIdx !== -1 ? stopsList.slice(0, endIdx + 1) : stopsList.slice();
+354 -61
View File
@@ -1,3 +1,4 @@
/* eslint-disable */
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import './AFLDeparts.css'; import './AFLDeparts.css';
import supabase from '../../../lib/supabase'; import supabase from '../../../lib/supabase';
@@ -6,7 +7,8 @@ import MarqueeText from '../../../components/MarqueeText';
import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; 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, resolveNominalTrack } from '../../../services/sncfApi';
import { matchStationNameStrict } from '../../../services/gtfsStations';
import AFLSecurityPage from './components/AFLSecurityPage'; import AFLSecurityPage from './components/AFLSecurityPage';
import AFLCorrespondancesPage from './components/AFLCorrespondancesPage'; import AFLCorrespondancesPage from './components/AFLCorrespondancesPage';
import AFLInformationsPage from './components/AFLInformationsPage'; import AFLInformationsPage from './components/AFLInformationsPage';
@@ -53,6 +55,215 @@ const BusIcon = ({ size = '59px', color = 'white', style = {} }) => (
</svg> </svg>
); );
// Icône Embarquement (Départs AFL)
const AFLEmbarquementIcon = ({ size = '49px', color = 'white', style = {} }) => (
<svg
viewBox="0 0 32 32"
width={size}
height={size}
fill="none"
style={{ flexShrink: 0, ...style }}
>
{/* Voiture de train / Porte à droite */}
<path d="M22 4v24M22 4h6M22 28h6" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 25h3" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<path d="M16 28h3" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
{/* Silhouette du passager montant dans le train (tourné vers la droite) */}
<circle cx="11.5" cy="8.5" r="2.5" fill={color} />
<path
d="M10 13h4l2 6-2.5 1-1-3.5-1.5 5.5-3-1 2-8z"
fill={color}
/>
<path d="M14 15l4-1.5" stroke={color} strokeWidth="2" strokeLinecap="round" />
</svg>
);
// Pastilles de modification de parcours
const AFLRouteBadge = ({ type = 'modified', size = '52px', style = {} }) => {
if (type === 'cancelled' || type === 'suppressed') {
return (
<svg
viewBox="0 0 36 36"
width={size}
height={size}
style={{ flexShrink: 0, marginRight: '14px', display: 'inline-block', verticalAlign: 'middle', ...style }}
>
<circle cx="18" cy="18" r="18" fill="#c8102e" />
<path d="M11 11L25 25M25 11L11 25" stroke="#ffffff" strokeWidth="4.2" strokeLinecap="round" />
</svg>
);
}
if (type === 'added') {
return (
<svg
viewBox="0 0 36 36"
width={size}
height={size}
style={{ flexShrink: 0, marginRight: '14px', display: 'inline-block', verticalAlign: 'middle', ...style }}
>
<circle cx="18" cy="18" r="18" fill="#0084ff" />
<path d="M18 9v18M9 18h18" stroke="#ffffff" strokeWidth="4.2" strokeLinecap="round" />
</svg>
);
}
return (
<svg
viewBox="0 0 36 36"
width={size}
height={size}
style={{ flexShrink: 0, marginRight: '14px', display: 'inline-block', verticalAlign: 'middle', ...style }}
>
<circle cx="18" cy="18" r="18" fill="#38d9f5" />
<rect x="15.8" y="8" width="4.4" height="12.5" rx="2.2" fill="#002d62" />
<circle cx="18" cy="26" r="2.5" fill="#002d62" />
</svg>
);
};
// Helper détection des modifications de parcours
const detectRouteModification = (horaire, allPerturbs = [], isDeparts = true, normalizeName = (s) => s) => {
if (!horaire) return { destModif: null, perturbStopsMap: new Map() };
const trainNo = String(horaire.numero_train || horaire.number || horaire.numero || '').trim();
const horaireId = String(horaire.id || '').trim();
const matchingPerturbs = (allPerturbs || []).filter(p => {
if (!p) return false;
const pRef = String(p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero || '').trim();
const pNum = String(p.number || p.train_number || p.numero || p.numero_train || '').trim();
if (horaireId && pRef === horaireId) return true;
if (trainNo && (pRef === trainNo || pNum === trainNo)) return true;
return false;
});
const perturbStopsMap = new Map();
let explicitNewDest = null;
let explicitOldDest = null;
let explicitNewOrigin = null;
let explicitOldOrigin = null;
matchingPerturbs.forEach(p => {
if (p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee) {
explicitNewDest = p.nouveau_terminus || p.nouvelle_destination || p.gare_arrivee_modifiee;
}
if (p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale) {
explicitOldDest = p.ancien_terminus || p.ancienne_destination || p.gare_arrivee_initiale;
}
if (p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee) {
explicitNewOrigin = p.nouvelle_origine || p.nouvelle_gare_depart || p.gare_depart_modifiee;
}
if (p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale) {
explicitOldOrigin = p.ancienne_origine || p.ancienne_gare_depart || p.gare_depart_initiale;
}
const rawArrets = p.perturbations_arrets || p.arrets || p.arrets_perturbations;
let arretsList = [];
if (Array.isArray(rawArrets)) {
arretsList = rawArrets;
} else if (rawArrets && typeof rawArrets === 'object' && Array.isArray(rawArrets.arrets)) {
arretsList = rawArrets.arrets;
} else if (typeof rawArrets === 'string') {
try {
const parsed = JSON.parse(rawArrets);
arretsList = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.arrets) ? parsed.arrets : []);
} catch (e) {}
}
arretsList.forEach(a => {
const aName = a.gare_nom || a.nom || a.name || a.station_name || (typeof a === 'string' ? a : '');
if (!aName) return;
const aType = String(a.type_perturbation || a.type || a.status || '').toLowerCase();
const norm = normalizeName(aName);
perturbStopsMap.set(norm, {
name: aName,
type: aType,
isSupprime: aType.includes('suppr') || aType.includes('annul') || aType.includes('canc'),
isAjoute: aType.includes('ajout') || aType.includes('add') || aType.includes('nouv'),
isModifie: aType.includes('modif')
});
});
});
if (horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination) {
explicitOldDest = horaire.destination_initiale || horaire.gare_arrivee_initiale || horaire.ancienne_destination;
}
if (horaire.nouvelle_destination || horaire.nouveau_terminus || (horaire.destination_modifiee && horaire.gare_arrivee_nom)) {
explicitNewDest = horaire.nouvelle_destination || horaire.nouveau_terminus || horaire.gare_arrivee_nom;
}
if (horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine) {
explicitOldOrigin = horaire.origine_initiale || horaire.gare_depart_initiale || horaire.ancienne_origine;
}
if (horaire.nouvelle_origine || (horaire.origine_modifiee && horaire.gare_depart_nom)) {
explicitNewOrigin = horaire.nouvelle_origine || horaire.gare_depart_nom;
}
let destModif = null;
if (isDeparts) {
const currentDest = horaire.gare_arrivee_nom || horaire.destination || '';
const normDest = normalizeName(currentDest);
const destInPerturb = perturbStopsMap.get(normDest);
if (explicitNewDest && explicitOldDest && normalizeName(explicitNewDest) !== normalizeName(explicitOldDest)) {
destModif = {
newStation: explicitNewDest,
oldStation: explicitOldDest,
isAdded: Boolean(destInPerturb?.isAjoute || /ajout/.test(String(horaire.type_modification || '')))
};
} else if (destInPerturb?.isSupprime) {
destModif = {
newStation: explicitNewDest || 'Terminus modifié',
oldStation: currentDest,
isAdded: false
};
} else if (destInPerturb?.isAjoute) {
destModif = {
newStation: currentDest,
oldStation: explicitOldDest || 'Destination modifiée',
isAdded: true
};
} else if (explicitOldDest && normalizeName(explicitOldDest) !== normDest) {
destModif = {
newStation: currentDest,
oldStation: explicitOldDest,
isAdded: Boolean(horaire.destination_ajoutee)
};
}
} else {
const currentOrig = horaire.gare_depart_nom || horaire.origin || '';
const normOrig = normalizeName(currentOrig);
const origInPerturb = perturbStopsMap.get(normOrig);
if (explicitNewOrigin && explicitOldOrigin && normalizeName(explicitNewOrigin) !== normalizeName(explicitOldOrigin)) {
destModif = {
newStation: explicitNewOrigin,
oldStation: explicitOldOrigin,
isAdded: Boolean(origInPerturb?.isAjoute)
};
} else if (origInPerturb?.isSupprime) {
destModif = {
newStation: explicitNewOrigin || 'Origine modifiée',
oldStation: currentOrig,
isAdded: false
};
} else if (origInPerturb?.isAjoute) {
destModif = {
newStation: currentOrig,
oldStation: explicitOldOrigin || 'Origine initiale',
isAdded: true
};
} else if (explicitOldOrigin && normalizeName(explicitOldOrigin) !== normOrig) {
destModif = {
newStation: currentOrig,
oldStation: explicitOldOrigin,
isAdded: Boolean(horaire.origine_ajoutee)
};
}
}
return { destModif, perturbStopsMap };
};
const ViasMarquee = ({ viaNames }) => { const ViasMarquee = ({ viaNames }) => {
const containerRef = useRef(null); const containerRef = useRef(null);
const textRef = useRef(null); const textRef = useRef(null);
@@ -66,7 +277,6 @@ const ViasMarquee = ({ viaNames }) => {
const tWidth = textRef.current.scrollWidth; const tWidth = textRef.current.scrollWidth;
if (tWidth > cWidth) { if (tWidth > cWidth) {
setIsOverflowing(true); setIsOverflowing(true);
// Vitesse de défilement multipliée par 2 (~280px/s)
setDuration(Math.max(1.0, tWidth / 280)); setDuration(Math.max(1.0, tWidth / 280));
} else { } else {
setIsOverflowing(false); setIsOverflowing(false);
@@ -97,18 +307,38 @@ const ViasMarquee = ({ viaNames }) => {
animationDuration: isOverflowing ? `${duration}s` : undefined animationDuration: isOverflowing ? `${duration}s` : undefined
}} }}
> >
{viaNames.map((v, i) => ( {viaNames.map((item, i) => {
<React.Fragment key={i}> const name = typeof item === 'string' ? item : (item.name || item.gare_nom || '');
{i > 0 && <span className="afl-vias-chevron">&gt;</span>} const isSupprime = typeof item === 'object' && Boolean(item.isSupprime);
<span className="afl-vias-stop">{v}</span> const isAjoute = typeof item === 'object' && Boolean(item.isAjoute);
</React.Fragment> const isModifie = typeof item === 'object' && Boolean(item.isModifie);
))}
return (
<React.Fragment key={i}>
{i > 0 && <span className="afl-vias-chevron">&gt;</span>}
<span
className="afl-vias-stop"
style={{
display: 'inline-flex',
alignItems: 'center',
...(isSupprime ? { textDecoration: 'line-through', opacity: 0.85 } : {})
}}
>
{isSupprime && <AFLRouteBadge type="cancelled" size="26px" style={{ marginRight: '6px' }} />}
{isAjoute && <AFLRouteBadge type="added" size="26px" style={{ marginRight: '6px' }} />}
{isModifie && <AFLRouteBadge type="modified" size="26px" style={{ marginRight: '6px' }} />}
<span>{name}</span>
</span>
</React.Fragment>
);
})}
</div> </div>
</div> </div>
</div> </div>
); );
}; };
// (La fonction TrainType a été retirée car la colonne affiche désormais directement les // (La fonction TrainType a été retirée car la colonne affiche désormais directement les
// informations via .train-meta dans la colonne .col-train-type) // informations via .train-meta dans la colonne .col-train-type)
@@ -227,12 +457,8 @@ export default function AFLDeparts(){
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle);
if (!gerr && gdata && gdata.length) { if (!gerr && gdata && gdata.length) {
const exactMatch = gdata.find(g => { const exactMatch = gdata.find(g => matchStationNameStrict(g.nom, gareDisplay));
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); const chosen = exactMatch || (gdata.length === 1 ? gdata[0] : null);
const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
return n1 === n2;
});
const chosen = exactMatch || gdata[0];
fetchedType = chosen ? chosen.type : null; fetchedType = chosen ? chosen.type : null;
fetchedId = chosen ? chosen.id : null; fetchedId = chosen ? chosen.id : null;
} }
@@ -639,7 +865,12 @@ export default function AFLDeparts(){
} }
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; if (maxDelay != null) {
const dH = Math.floor(maxDelay / 60);
const dRem = maxDelay % 60;
const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`;
return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay };
}
return { label: "à l'heure", detail: '', delayMinutes: null }; return { label: "à l'heure", detail: '', delayMinutes: null };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
@@ -670,17 +901,18 @@ export default function AFLDeparts(){
const stopForStation = (stops || []).find(s => { const stopForStation = (stops || []).find(s => {
const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';
const ns = normalizeName(nm || ''); return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && matchStationNameStrict(nm, targetStationName));
const target = normalizeName(targetStationName || '');
return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target);
}) || null; }) || null;
horaire._status = displayStatus; horaire._status = displayStatus;
horaire._displayTime = displayTime; horaire._displayTime = displayTime;
const platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || ''; let platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform || stopForStation.voie)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || horaire.voie || '';
horaire._displayPlatform = String(platformVal).trim(); if (!platformVal) {
platformVal = resolveNominalTrack(targetStationName || gareDisplay, horaire.numero_train || horaire.num, targetStationId || gareIdParam);
}
horaire._displayPlatform = String(platformVal || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
return horaire; return horaire;
}); });
}, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, gareParam, getStops, normalizeName]);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setShowStatus(s => !s), 3000); const id = setInterval(() => setShowStatus(s => !s), 3000);
@@ -710,18 +942,22 @@ export default function AFLDeparts(){
if (sncfErr) { if (sncfErr) {
setError(sncfErr); setError(sncfErr);
} else { } else {
const formatted = (sncfData || []).map(t => ({ const formatted = (sncfData || []).map(t => {
...t, const depHM = (typeof t._depHM === 'number') ? t._depHM : parseTimeHM(t.heure_depart);
_displayTime: formatTimeNoSeconds(t.heure_depart), const plat = String(t._displayPlatform || t.quai_reel || t.quai || t.voie || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
_displayPlatform: t.quai || t.voie || '', return {
_depHM: (typeof t._depHM === 'number') ? t._depHM : parseTimeHM(t.heure_depart), ...t,
_status: t._status || { _displayTime: formatTimeNoSeconds(t.heure_depart),
label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"), _displayPlatform: plat,
detail: '', _depHM: depHM,
delayMinutes: t.retard_minutes || null, _status: t._status || {
causeDetails: t.cause_retard || null label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"),
} detail: '',
})); delayMinutes: t.retard_minutes || null,
causeDetails: t.cause_retard || null
}
};
});
setHoraires(formatted); setHoraires(formatted);
setAllHoraires(formatted); setAllHoraires(formatted);
} }
@@ -795,15 +1031,14 @@ export default function AFLDeparts(){
// Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus) // Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus)
let displayHoraires = parsedHoraires.slice(); let displayHoraires = parsedHoraires.slice();
if (gareDisplay) { if (gareDisplay) {
const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));
displayHoraires = displayHoraires.filter(h => { displayHoraires = displayHoraires.filter(h => {
// Exclure si la gare ciblée est la gare d'arrivée finale // Exclure si la gare ciblée est la gare d'arrivée finale
const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); const destName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString();
if (destName === normalized) return false; if (matchStationNameStrict(destName, gareDisplay)) return false;
// Inclure si gare de départ correspond // Inclure si gare de départ correspond
const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString()); const departName = (h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString();
if (departName === normalized) return true; if (matchStationNameStrict(departName, gareDisplay)) return true;
// Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max) // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max)
const stopsList = getStops(h); const stopsList = getStops(h);
@@ -816,11 +1051,11 @@ export default function AFLDeparts(){
} }
} }
for (const s of stopsList) { for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); const stopName = (s.gare_nom || s.nom || s.name || s.stop_name || '').toString();
if (!stopName) continue; if (!stopName) continue;
const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx)); const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
if (isTerminus) continue; if (isTerminus) continue;
if (stopName === normalized) return true; if (matchStationNameStrict(stopName, gareDisplay)) return true;
} }
} }
return false; return false;
@@ -893,7 +1128,7 @@ export default function AFLDeparts(){
const stopsListForH = getStops(h); const stopsListForH = getStops(h);
if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) { if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) {
const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null; const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;
const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; const byName = gareDisplay ? stopsListForH.find(s => matchStationNameStrict(s.gare_nom || s.nom || s.name || '', gareDisplay)) : null;
const stop = byId || byName; const stop = byId || byName;
if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure; if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure;
} }
@@ -1162,7 +1397,7 @@ export default function AFLDeparts(){
const intervalId = setInterval(fetchData, intervalMs); const intervalId = setInterval(fetchData, intervalMs);
return () => { mounted = false; clearInterval(intervalId); }; return () => { mounted = false; clearInterval(intervalId); };
}, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]); }, [gareDisplay, gareParam, gareIdParam]);
// Helpers pour perturbations (memoized plus haut) // Helpers pour perturbations (memoized plus haut)
@@ -1274,16 +1509,7 @@ export default function AFLDeparts(){
{/* HEADER */} {/* HEADER */}
<header className="afl-header"> <header className="afl-header">
<div className="afl-header-left"> <div className="afl-header-left">
<svg width="49px" height="49px" viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}> <AFLEmbarquementIcon size="49px" color="white" />
{/* Chevrons >> */}
<path d="M3 8l4 6-4 6M8 8l4 6-4 6" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
{/* Silhouette tête */}
<circle cx="17.5" cy="7" r="2.2" fill="white" />
{/* Silhouette corps + marche */}
<path d="M15.5 11h4l1.5 5.5-2 1-1-3.5-1 5.5-3-1 2-7.5z" fill="white" />
{/* Porte de train verticale */}
<path d="M23 4v20M23 4h3M23 24h3" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div className="afl-header-title">Départs</div> <div className="afl-header-title">Départs</div>
</div> </div>
@@ -1325,7 +1551,17 @@ export default function AFLDeparts(){
).toString().trim(); ).toString().trim();
const isSub = (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero)); const isSub = (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero));
const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR'); const isCarTER = (
op === 'CAR TER' ||
op === 'AUTOCAR' ||
op.includes('AUTOCAR') ||
op.includes('CAR') ||
op.includes('BUS') ||
rawPlatform === 'AUTOCAR' ||
rawPlatform === 'CAR' ||
rawPlatform === 'BUS' ||
isSub
);
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 || '';
@@ -1341,20 +1577,34 @@ export default function AFLDeparts(){
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';
let delayVal = '+ 5 min'; let delayVal = '+ 5 min';
const formatDelayVal = (m) => {
if (m >= 60) {
const h = Math.floor(m / 60);
const rem = m % 60;
return rem > 0 ? `+ ${h}h${String(rem).padStart(2, '0')}` : `+ ${h}h`;
}
return `+ ${m} min`;
};
if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) {
delayVal = `+ ${status.delayMinutes} min`; delayVal = formatDelayVal(status.delayMinutes);
} else if (status?.delayMinutes) { } else if (status?.delayMinutes) {
delayVal = `+ ${status.delayMinutes} min`; const parsed = parseInt(status.delayMinutes, 10);
delayVal = !isNaN(parsed) && parsed > 0 ? formatDelayVal(parsed) : `+ ${status.delayMinutes} min`;
} else if (status?.label && status.label.includes('+')) { } else if (status?.label && status.label.includes('+')) {
delayVal = status.label; const m = status.label.match(/\+(\d+)\s*(?:min)?/);
if (m) delayVal = formatDelayVal(parseInt(m[1], 10));
else delayVal = status.label;
} }
let delayDisplayText = alternateDelay ? 'retardé' : delayVal; let delayDisplayText = alternateDelay ? 'retardé' : delayVal;
let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure'; let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure';
// Détection des modifications de parcours
const { destModif, perturbStopsMap } = detectRouteModification(horaire, perturbations, true, normalizeName);
// Calcul des vias // Calcul des vias
const stopsList = getStops(horaire); const stopsList = getStops(horaire);
const viaNames = []; const rawViaStops = [];
if (Array.isArray(stopsList) && stopsList.length > 0) { if (Array.isArray(stopsList) && stopsList.length > 0) {
let isAfterStart = false; let isAfterStart = false;
const hasStartInStops = stopsList.some(s => { const hasStartInStops = stopsList.some(s => {
@@ -1377,11 +1627,26 @@ export default function AFLDeparts(){
if (norm === normalizeName(destination || '')) { if (norm === normalizeName(destination || '')) {
break; break;
} }
viaNames.push(sName); rawViaStops.push(s);
} }
} }
} }
const viaItems = rawViaStops.map(s => {
const sName = s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : '');
const norm = normalizeName(sName);
const pInfo = perturbStopsMap.get(norm);
const isSupprime = Boolean(pInfo?.isSupprime || s.is_supprime || s.supprime || s.type === 'arret_supprime');
const isAjoute = Boolean(pInfo?.isAjoute || s.is_ajoute || s.ajoute || s.type === 'arret_ajoute');
const isModifie = Boolean(pInfo?.isModifie || s.is_modifie || s.modifie);
return {
name: sName,
isSupprime,
isAjoute,
isModifie
};
});
const renderPillIcon = () => { const renderPillIcon = () => {
if (isSuppressed) { if (isSuppressed) {
return ( return (
@@ -1463,16 +1728,44 @@ export default function AFLDeparts(){
<> <>
<div className="afl-top-row"> <div className="afl-top-row">
{isCarTER && <BusIcon size="52px" color="white" style={{ marginRight: '15px' }} />} {isCarTER && <BusIcon size="52px" color="white" style={{ marginRight: '15px' }} />}
<MarqueeText text={destination} className="afl-text-dest" style={{ flex: 'none' }} /> {destModif ? (
alternateDelay ? (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type={destModif.isAdded ? 'added' : 'modified'} size="52px" />
<MarqueeText text={destModif.newStation} className="afl-text-dest" style={{ flex: 'none' }} />
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type="cancelled" size="52px" />
<MarqueeText text={destModif.oldStation} className="afl-text-dest" style={{ flex: 'none' }} />
</div>
)
) : (
<MarqueeText text={destination} className="afl-text-dest" style={{ flex: 'none' }} />
)}
</div> </div>
<div className="afl-bottom-row"> <div className="afl-bottom-row">
<ViasMarquee viaNames={viaNames} /> <ViasMarquee viaNames={viaItems} />
</div> </div>
</> </>
) : ( ) : (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}> <div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
{isCarTER && <BusIcon size="49px" color="white" style={{ marginRight: '15px' }} />} {isCarTER && <BusIcon size="49px" color="white" style={{ marginRight: '15px' }} />}
<MarqueeText text={destination} className="afl-text-dest" style={{ flex: 'none' }} /> {destModif ? (
alternateDelay ? (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type={destModif.isAdded ? 'added' : 'modified'} size="46px" />
<MarqueeText text={destModif.newStation} className="afl-text-dest" style={{ flex: 'none' }} />
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}>
<AFLRouteBadge type="cancelled" size="46px" />
<MarqueeText text={destModif.oldStation} className="afl-text-dest" style={{ flex: 'none' }} />
</div>
)
) : (
<MarqueeText text={destination} className="afl-text-dest" style={{ flex: 'none' }} />
)}
</div> </div>
)} )}
</div> </div>
+107 -30
View File
@@ -6,6 +6,12 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
import Logo from '../../../components/Logo'; import Logo from '../../../components/Logo';
import { getSourceFromPath } from '../../../services/railwayService'; import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi'; import { getSncfTimetable } from '../../../services/sncfApi';
import { matchStationNameStrict } from '../../../services/gtfsStations';
import GareVideoprotection from '../../../components/afficheurs/GareVideoprotection';
import AttentifsEnsemble from '../../../components/afficheurs/AttentifsEnsemble';
import DepartEnCoursAFL from '../../../components/afficheurs/DepartEnCoursAFL';
import AFLInformationsPage from './components/AFLInformationsPage';
import AFLDeparts from './AFLDeparts';
// Helper utilitaire : format HH:MM sans secondes (ex: 15:57) // Helper utilitaire : format HH:MM sans secondes (ex: 15:57)
const formatTimeNoSeconds = (v) => { const formatTimeNoSeconds = (v) => {
@@ -28,6 +34,7 @@ const AFLDepartsQuais = () => {
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const [nextDeparture, setNextDeparture] = useState(null); const [nextDeparture, setNextDeparture] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [idlePhase, setIdlePhase] = useState(0); // 0 = Attentifs (20s), 1 = Quai (20s), 2 = Information (10s)
// Parse URL manually since we are not in a React Router context // Parse URL manually since we are not in a React Router context
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
@@ -36,6 +43,10 @@ const AFLDepartsQuais = () => {
const quaiParam = searchParams.get('quai') || searchParams.get('voie') || ''; const quaiParam = searchParams.get('quai') || searchParams.get('voie') || '';
const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;
const pageParam = (searchParams.get('page') || searchParams.get('mode') || '').toLowerCase();
const isVideoprotection = pageParam === 'videoprotection' || searchParams.get('videoprotection') === 'true';
const isForceDepartEnCours = pageParam === 'depart_en_cours' || searchParams.get('depart_en_cours') === 'true' || searchParams.get('test_depart') === 'true';
const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null);
useAnnonceSonoreRealtime(resolvedGareId, 'AFLDepartsQuais', quaiParam); useAnnonceSonoreRealtime(resolvedGareId, 'AFLDepartsQuais', quaiParam);
@@ -435,22 +446,36 @@ const AFLDepartsQuais = () => {
quai: quaiParam quai: quaiParam
}); });
if (sncfData && sncfData.length > 0) { if (sncfData && sncfData.length > 0) {
const train = sncfData[0]; let matching = sncfData;
const enriched = { if (quaiParam) {
...train, const cleanP = (p) => String(p || '').toLowerCase().replace(/^(?:voie|quai|track|platform)\s*/i, '').replace(/\s+/g, '').trim();
_rawTime: train.heure_depart, matching = sncfData.filter(t => cleanP(t._displayPlatform || t.quai || t.voie) === cleanP(quaiParam));
_displayTime: formatTimeNoSeconds(train.heure_depart), }
_status: { const train = matching[0];
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure"), if (train) {
detail: '', const trainPlatform = train._displayPlatform || train.quai || train.voie || quaiParam || '';
delayMinutes: train.retard_minutes || null const enriched = {
}, ...train,
_nextStationStatus: { quai: trainPlatform,
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure") quai_reel: trainPlatform,
}, voie: trainPlatform,
_isToday: true _displayPlatform: trainPlatform,
}; _rawTime: train.heure_depart,
setNextDeparture(enriched); _displayTime: formatTimeNoSeconds(train.heure_depart),
_status: {
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure"),
detail: '',
delayMinutes: train.retard_minutes || null
},
_nextStationStatus: {
label: train.etat === 'supprime' ? 'supprimé' : (train.retard_minutes > 0 ? `retardé +${train.retard_minutes}min.` : "à l'heure")
},
_isToday: true
};
setNextDeparture(enriched);
} else {
setNextDeparture(null);
}
} else { } else {
setNextDeparture(null); setNextDeparture(null);
} }
@@ -653,13 +678,12 @@ const AFLDepartsQuais = () => {
// Filter by station: keep departures from this station or stops at this station (excluding terminus) // Filter by station: keep departures from this station or stops at this station (excluding terminus)
let stationFiltered = enriched; let stationFiltered = enriched;
if (gareDisplay) { if (gareDisplay) {
const normalized = normalizeName(gareDisplay);
stationFiltered = enriched.filter(h => { stationFiltered = enriched.filter(h => {
const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); const destName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString();
if (destName === normalized) return false; if (matchStationNameStrict(destName, gareDisplay)) return false;
const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); const departName = (h.gare_depart_nom || h.gare_depart || '').toString();
if (departName === normalized) return true; if (matchStationNameStrict(departName, gareDisplay)) return true;
const stopsList = getStops(h); const stopsList = getStops(h);
if (Array.isArray(stopsList) && stopsList.length) { if (Array.isArray(stopsList) && stopsList.length) {
@@ -671,11 +695,11 @@ const AFLDepartsQuais = () => {
} }
} }
for (const s of stopsList) { for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || '').toString()); const stopName = (s.gare_nom || s.nom || s.name || '').toString();
if (!stopName) continue; if (!stopName) continue;
const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx)); const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
if (isTerminus) continue; if (isTerminus) continue;
if (stopName === normalized) return true; if (matchStationNameStrict(stopName, gareDisplay)) return true;
} }
} }
return false; return false;
@@ -741,9 +765,10 @@ const AFLDepartsQuais = () => {
if (gareDisplay && Array.isArray(stopsList)) { if (gareDisplay && Array.isArray(stopsList)) {
stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay));
} }
const platform = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || h.quai_reel || h.voie || h.quai || '').toString().trim(); const platform = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay || stopForStation.voie)) || h.quai_reel || h.voie || h.quai || '').toString().trim();
if (!quaiParam) return true; if (!quaiParam) return true;
return platform.toLowerCase() === quaiParam.toLowerCase(); const cleanP = (p) => String(p || '').toLowerCase().replace(/^(?:voie|quai|track|platform)\s*/i, '').replace(/\s+/g, '').trim();
return cleanP(platform) === cleanP(quaiParam);
}); });
if (candidatesOnPlatform.length > 0) { if (candidatesOnPlatform.length > 0) {
@@ -758,13 +783,66 @@ const AFLDepartsQuais = () => {
} }
}, [applyPerturbationsToHoraires, buildPerturbMap, gareDisplay, gareParam, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]); }, [applyPerturbationsToHoraires, buildPerturbMap, gareDisplay, gareParam, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]);
// Alternance par défaut (20s Attentifs -> 20s Quai -> 10s Information) si aucun train n'est prévu sur le quai
useEffect(() => {
if (nextDeparture) return;
let duration = 20000;
if (idlePhase === 2) duration = 10000;
else duration = 20000;
const timer = setTimeout(() => {
setIdlePhase(prev => (prev + 1) % 3);
}, duration);
return () => clearTimeout(timer);
}, [idlePhase, nextDeparture]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
const interval = setInterval(fetchData, 15000); const interval = setInterval(fetchData, 15000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchData]); }, [fetchData]);
if (loading) return <div className="afl-q-container" style={{ background: '#0c0f2e' }}></div>; // 1. Détection "Départ en cours" (2 minutes avant l'heure de départ réelle)
let isDepartEnCours = isForceDepartEnCours;
if (!isDepartEnCours && nextDeparture) {
const rawTime = nextDeparture._rawTime || nextDeparture.heure_depart || nextDeparture.time;
const depHM = parseTimeHM(rawTime);
const delayMin = nextDeparture._status?.delayMinutes || nextDeparture.retard_minutes || 0;
if (depHM != null) {
const nowSec = currentTime.getHours() * 3600 + currentTime.getMinutes() * 60 + currentTime.getSeconds();
const depSec = (depHM + delayMin) * 60;
let diffSec = depSec - nowSec;
if (diffSec < -43200) diffSec += 86400;
if (diffSec <= 120 && diffSec >= 0) {
isDepartEnCours = true;
}
}
}
// AFFICHAGE 1 : Page Vidéoprotection (via query)
if (isVideoprotection) {
return <GareVideoprotection />;
}
// AFFICHAGE 2 : Départ en cours (T - 2 min)
if (isDepartEnCours) {
return <DepartEnCoursAFL />;
}
// AFFICHAGE 3 : Alternance si aucun train n'est attribué à ce quai
if (!nextDeparture && gareParam) {
if (idlePhase === 0) {
return <AttentifsEnsemble />;
}
if (idlePhase === 1) {
return <AFLDeparts />;
}
if (idlePhase === 2) {
return <AFLInformationsPage mode="departs" gareName={gareDisplay || 'Votre Gare'} />;
}
}
if (loading && !nextDeparture) return <div className="afl-q-container" style={{ background: '#0c0f2e' }}></div>;
if (!nextDeparture) { if (!nextDeparture) {
return ( return (
@@ -900,10 +978,9 @@ const AFLDepartsQuais = () => {
let downstreamStops = []; let downstreamStops = [];
if (Array.isArray(stopsList) && stopsList.length > 0) { if (Array.isArray(stopsList) && stopsList.length > 0) {
const normCurrent = normalizeName(currentStationName);
const startIdx = stopsList.findIndex(s => { const startIdx = stopsList.findIndex(s => {
const sName = normalizeName((s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : '')).toString()); const sName = (s.gare_nom || s.nom || s.name || s.station_name || (typeof s === 'string' ? s : '')).toString();
return sName && (sName === normCurrent || sName.includes(normCurrent) || normCurrent.includes(sName)); return sName && matchStationNameStrict(sName, currentStationName);
}); });
const rawDownstream = startIdx !== -1 ? stopsList.slice(startIdx + 1) : stopsList; const rawDownstream = startIdx !== -1 ? stopsList.slice(startIdx + 1) : stopsList;
@@ -912,7 +989,7 @@ const AFLDepartsQuais = () => {
rawDownstream.forEach(s => { rawDownstream.forEach(s => {
const name = (s.gare_nom || s.nom || s.name || s.station_name || s.stop_name || (typeof s === 'string' ? s : '')).toString().trim(); const name = (s.gare_nom || s.nom || s.name || s.station_name || s.stop_name || (typeof s === 'string' ? s : '')).toString().trim();
const norm = normalizeName(name); const norm = normalizeName(name);
if (name && norm !== normCurrent && !seen.has(norm)) { if (name && !matchStationNameStrict(name, currentStationName) && !seen.has(norm)) {
seen.add(norm); seen.add(norm);
downstreamStops.push(name); downstreamStops.push(name);
} }
@@ -162,7 +162,7 @@
.aff-classiques-arrivees__stop-bullet { .aff-classiques-arrivees__stop-bullet {
color: var(--yellow); color: var(--yellow);
font-size: 50px; font-size: 46px;
margin-right: 14px; margin-right: 14px;
margin-left: 18px; margin-left: 18px;
line-height: 1; line-height: 1;
@@ -174,12 +174,12 @@
} }
.aff-classiques-arrivees__stop-name { .aff-classiques-arrivees__stop-name {
color: var(--white); color: #d0d8e2;
font-size: 58px; font-size: 62px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
font-family: "Achemine", sans-serif; font-family: "Achemine", sans-serif;
letter-spacing: -0.5px; letter-spacing: -0.3px;
} }
/* Colonne 1 : Logo opérateur (blanc) */ /* Colonne 1 : Logo opérateur (blanc) */
@@ -203,8 +203,8 @@
/* Colonne 2 : Type de train + Numéro OU Statut */ /* Colonne 2 : Type de train + Numéro OU Statut */
.aff-classiques-arrivees__col-meta { .aff-classiques-arrivees__col-meta {
width: 185px; width: 200px;
min-width: 185px; min-width: 200px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -217,53 +217,72 @@
} }
.aff-classiques-arrivees__train-type { .aff-classiques-arrivees__train-type {
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--white); color: var(--white);
line-height: 1.1; line-height: 1.1;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap; white-space: nowrap;
font-family: "Achemine", sans-serif;
} }
.aff-classiques-arrivees__train-number { .aff-classiques-arrivees__train-number {
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--white); color: var(--white);
line-height: 1.1; line-height: 1.1;
margin-top: 4px; margin-top: 4px;
white-space: nowrap; white-space: nowrap;
font-family: "Achemine", sans-serif;
} }
.aff-classiques-arrivees__status-ontime { .aff-classiques-arrivees__status-ontime {
color: var(--white); color: var(--white);
font-size: 28px; font-size: 32px;
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.1;
white-space: nowrap; white-space: nowrap;
text-transform: lowercase; text-transform: lowercase;
font-family: "Achemine", sans-serif;
} }
.aff-classiques-arrivees__status-delayed { .aff-classiques-arrivees__status-delayed {
color: var(--yellow); color: var(--yellow);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
font-family: "Achemine", sans-serif;
line-height: 1.05;
}
.aff-classiques-arrivees__status-delayed-label {
font-size: 24px;
font-weight: 700;
color: var(--yellow);
text-transform: lowercase;
}
.aff-classiques-arrivees__status-delayed-time {
font-size: 26px; font-size: 26px;
font-weight: 700; font-weight: 700;
line-height: 1.1; color: var(--yellow);
white-space: nowrap;
} }
.aff-classiques-arrivees__status-suppressed { .aff-classiques-arrivees__status-suppressed {
color: #ff4d4d; color: #ff4d4d;
font-size: 28px; font-size: 32px;
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.1;
white-space: nowrap; white-space: nowrap;
text-transform: lowercase; text-transform: lowercase;
font-family: "Achemine", sans-serif;
} }
/* Colonne 3 : Heure (Grande, jaune vif) */ /* Colonne 3 : Heure (Grande, jaune vif) */
.aff-classiques-arrivees__col-time { .aff-classiques-arrivees__col-time {
width: 250px; width: 260px;
min-width: 250px; min-width: 260px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@@ -276,7 +295,7 @@
.aff-classiques-arrivees__time { .aff-classiques-arrivees__time {
color: var(--yellow); color: var(--yellow);
font-size: 68px; font-size: 76px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
letter-spacing: -0.5px; letter-spacing: -0.5px;
@@ -302,19 +321,21 @@
.aff-classiques-arrivees__destination { .aff-classiques-arrivees__destination {
color: var(--white); color: var(--white);
font-size: 62px; font-size: 70px;
font-weight: 600; font-weight: 700;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-family: "Achemine", sans-serif; font-family: "Achemine", sans-serif;
letter-spacing: -0.4px;
} }
/* Colonne 5 : Quai / Voie (Premier plan absolu z-index: 20, au-dessus du watermark) */ /* Colonne 5 : Quai / Voie (Premier plan absolu z-index: 20, au-dessus du watermark) */
.aff-classiques-arrivees__col-platform { .aff-classiques-arrivees__col-platform {
width: 96px; width: 96px;
min-width: 96px; min-width: 96px;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -325,10 +346,10 @@
} }
.aff-classiques-arrivees__platform-box { .aff-classiques-arrivees__platform-box {
width: 88px; width: 86px;
height: 84px; height: 86px;
border: 3.5px solid var(--white); border: 3px solid var(--white);
border-radius: 12px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -356,8 +377,8 @@
justify-content: space-between; justify-content: space-between;
box-sizing: border-box; box-sizing: border-box;
padding-left: 24px; padding-left: 24px;
padding-right: 12px; padding-right: 14px;
background: linear-gradient(180deg, #13571d 0%, #0a2d0f 100%) !important; background: linear-gradient(180deg, #165b21 0%, #0a2d0f 100%) !important;
position: relative; position: relative;
z-index: 25; z-index: 25;
border-top: 1.5px solid rgba(255, 255, 255, 0.18); border-top: 1.5px solid rgba(255, 255, 255, 0.18);
@@ -386,23 +407,22 @@
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
border: 2.5px solid rgba(255, 255, 255, 0.85); border: 2.5px solid rgba(255, 255, 255, 0.85);
border-radius: 7px; border-radius: 6px;
padding: 6px 14px 4px 14px; padding: 6px 14px 4px 14px;
background: rgba(10, 42, 16, 0.3); background: rgba(10, 42, 16, 0.45);
font-family: "Achemine", sans-serif; font-family: "Achemine", sans-serif;
color: var(--white);
flex-shrink: 0; flex-shrink: 0;
margin-right: 4px; margin-right: 4px;
box-sizing: border-box; box-sizing: border-box;
height: 76px; height: 74px;
gap: 8px;
} }
.aff-classiques-arrivees__bottom-clock-hm { .aff-classiques-arrivees__bottom-clock-hm {
font-size: 54px; font-size: 52px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
letter-spacing: 2px; letter-spacing: 1.5px;
margin-right: 8px;
color: #ffffff; color: #ffffff;
} }
@@ -410,7 +430,9 @@
font-size: 34px; font-size: 34px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
margin-top: 1px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--yellow) !important; color: var(--yellow) !important;
margin-top: 1px;
margin-left: 2px;
} }
@@ -1,3 +1,4 @@
/* eslint-disable */
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import './ClassiquesArrivees.css'; import './ClassiquesArrivees.css';
import supabase from '../../../lib/supabase'; import supabase from '../../../lib/supabase';
@@ -6,9 +7,23 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce';
import { fetchAllRows } from '../../../lib/fetchAllRows'; import { fetchAllRows } from '../../../lib/fetchAllRows';
import { getSourceFromPath } from '../../../services/railwayService'; import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi'; import { getSncfTimetable, resolveNominalTrack } from '../../../services/sncfApi';
import { matchStationNameStrict } from '../../../services/gtfsStations';
import { getTrainLogoAndMeta } from '../../../utils/trainLogoHelper'; import { getTrainLogoAndMeta } from '../../../utils/trainLogoHelper';
// Pictogramme Autocar SNCF / MOBIGO
const AutocarIcon = ({ size = '50px', color = '#ffffff', style = {} }) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill={color}
style={{ flexShrink: 0, ...style }}
>
<path d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z" />
</svg>
);
// Helper utilitaire : format HHhMM sans secondes (ex: 06h44) // Helper utilitaire : format HHhMM sans secondes (ex: 06h44)
const formatTimeNoSeconds = (v) => { const formatTimeNoSeconds = (v) => {
if (v == null) return ''; if (v == null) return '';
@@ -143,7 +158,7 @@ function Clock(){
); );
} }
export default function ClassiquesArrivees(){ export default function ClassiquesArrivees({ defilement = null } = {}){
// lire le paramètre de requête ?gare=Nom-de-la-gare // lire le paramètre de requête ?gare=Nom-de-la-gare
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const gareParam = params.get('gare'); const gareParam = params.get('gare');
@@ -151,8 +166,8 @@ export default function ClassiquesArrivees(){
const gareIdParam = params.get('gare_id') || params.get('gareId'); const gareIdParam = params.get('gare_id') || params.get('gareId');
// 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;
// Mode défilement (marquee vertical infini) si ?defilement=true dans l'URL // Mode défilement (marquee vertical infini) si ?defilement=true dans l'URL ou passé en prop
const defilementMode = params.get('defilement') === 'true'; const defilementMode = defilement !== null ? Boolean(defilement) : (params.get('defilement') === 'true');
// 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');
@@ -212,12 +227,8 @@ export default function ClassiquesArrivees(){
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle);
if (!gerr && gdata && gdata.length) { if (!gerr && gdata && gdata.length) {
const exactMatch = gdata.find(g => { const exactMatch = gdata.find(g => matchStationNameStrict(g.nom, gareDisplay));
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); const chosen = exactMatch || (gdata.length === 1 ? gdata[0] : null);
const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
return n1 === n2;
});
const chosen = exactMatch || gdata[0];
fetchedType = chosen ? chosen.type : null; fetchedType = chosen ? chosen.type : null;
fetchedId = chosen ? chosen.id : null; fetchedId = chosen ? chosen.id : null;
} }
@@ -619,7 +630,12 @@ export default function ClassiquesArrivees(){
} }
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; if (maxDelay != null) {
const dH = Math.floor(maxDelay / 60);
const dRem = maxDelay % 60;
const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`;
return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails };
}
return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
@@ -650,21 +666,20 @@ export default function ClassiquesArrivees(){
const stopForStation = (stops || []).find(s => { const stopForStation = (stops || []).find(s => {
const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';
const ns = normalizeName(nm || ''); return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && matchStationNameStrict(nm, targetStationName));
const target = normalizeName(targetStationName || '');
return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target);
}) || null; }) || null;
horaire._status = displayStatus; let platformVal = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay || stopForStation.voie)) || horaire.quai || horaire.quai_reel || horaire.platform || horaire.voie || '';
horaire._displayTime = displayTime; if (!platformVal) {
// utiliser quai en priorité platformVal = resolveNominalTrack(targetStationName || gareDisplay, horaire.numero_train || horaire.num, targetStationId || gareIdParam);
horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay)) || horaire.quai || horaire.quai_reel || horaire.platform || ''; }
horaire._displayPlatform = String(platformVal || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
return horaire; return horaire;
}); });
}, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, gareParam, getStops, normalizeName]);
useEffect(() => { useEffect(() => {
// Alternance toutes les 4 secondes (type+numéro ↔ statut) // Alternance toutes les 2 secondes (type+numéro ↔ statut / suppression)
const id = setInterval(() => setShowStatus(s => !s), 4000); const id = setInterval(() => setShowStatus(s => !s), 2000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
@@ -686,12 +701,17 @@ export default function ClassiquesArrivees(){
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
let inFlight = false; // empêche les fetchs concurrents let inFlight = false; // empêche les fetchs concurrents
let pendingRefresh = false;
const source = getSourceFromPath(window.location.pathname); const source = getSourceFromPath(window.location.pathname);
const fetchData = async () => { const fetchData = async () => {
if (!mounted) return; if (!mounted) return;
if (inFlight) return; if (inFlight) {
pendingRefresh = true;
return;
}
inFlight = true; inFlight = true;
pendingRefresh = false;
if (source === 'sncf') { if (source === 'sncf') {
setLoading(true); setLoading(true);
@@ -707,11 +727,13 @@ export default function ClassiquesArrivees(){
} else { } else {
const formatted = (sncfData || []).map(t => { const formatted = (sncfData || []).map(t => {
const timeStr = t.heure_arrivee || t.heure_depart || t.time || ''; const timeStr = t.heure_arrivee || t.heure_depart || t.time || '';
const arrHM = (typeof t._arrHM === 'number') ? t._arrHM : parseTimeHM(timeStr);
const plat = String(t._displayPlatform || t.quai_reel || t.quai || t.voie || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
return { return {
...t, ...t,
_displayTime: formatTimeNoSeconds(timeStr), _displayTime: formatTimeNoSeconds(timeStr),
_displayPlatform: t.quai || t.voie || '', _displayPlatform: plat,
_arrHM: (typeof t._arrHM === 'number') ? t._arrHM : parseTimeHM(timeStr), _arrHM: arrHM,
_status: t._status || { _status: t._status || {
label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"), label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"),
detail: '', detail: '',
@@ -720,14 +742,20 @@ export default function ClassiquesArrivees(){
} }
}; };
}); });
setHoraires(formatted); const maxCount = defilementMode ? 20 : 10;
setAllHoraires(formatted); const limited = (formatted || []).slice(0, maxCount);
setHoraires(limited);
setAllHoraires(limited);
} }
} catch (e) { } catch (e) {
if (mounted) setError(e.message || String(e)); if (mounted) setError(e.message || String(e));
} finally { } finally {
if (mounted) setLoading(false); if (mounted) setLoading(false);
inFlight = false; inFlight = false;
if (pendingRefresh && mounted) {
pendingRefresh = false;
fetchData();
}
} }
return; return;
} }
@@ -791,15 +819,14 @@ export default function ClassiquesArrivees(){
// Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus) // Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus)
let displayHoraires = parsedHoraires.slice(); let displayHoraires = parsedHoraires.slice();
if (gareDisplay) { if (gareDisplay) {
const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));
displayHoraires = displayHoraires.filter(h => { displayHoraires = displayHoraires.filter(h => {
// Exclure si la gare ciblée est la gare de départ // Exclure si la gare ciblée est la gare de départ
const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString()); const departName = (h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString();
if (departName === normalized) return false; if (matchStationNameStrict(departName, gareDisplay)) return false;
// Inclure si gare d'arrivée correspond // Inclure si gare d'arrivée correspond
const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); const destName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString();
if (destName === normalized) return true; if (matchStationNameStrict(destName, gareDisplay)) return true;
// Chercher dans les arrêts desservis ; ignorer si l'arrêt est le point de départ (isFirst ou index==0) // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le point de départ (isFirst ou index==0)
const stopsList = getStops(h); const stopsList = getStops(h);
@@ -812,11 +839,11 @@ export default function ClassiquesArrivees(){
} }
} }
for (const s of stopsList) { for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); const stopName = (s.gare_nom || s.nom || s.name || s.stop_name || '').toString();
if (!stopName) continue; if (!stopName) continue;
const isOrigine = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && minIdx != null && Number(s.index) === minIdx)); const isOrigine = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && minIdx != null && Number(s.index) === minIdx));
if (isOrigine) continue; if (isOrigine) continue;
if (stopName === normalized) return true; if (matchStationNameStrict(stopName, gareDisplay)) return true;
} }
} }
return false; return false;
@@ -889,7 +916,7 @@ export default function ClassiquesArrivees(){
const stopsListForH = getStops(h); const stopsListForH = getStops(h);
if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) { if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) {
const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null; const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;
const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; const byName = gareDisplay ? stopsListForH.find(s => matchStationNameStrict(s.gare_nom || s.nom || s.name || '', gareDisplay)) : null;
const stop = byId || byName; const stop = byId || byName;
if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure; if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure;
} }
@@ -1157,18 +1184,40 @@ export default function ClassiquesArrivees(){
const intervalMs = 15000; // 15 secondes par défaut const intervalMs = 15000; // 15 secondes par défaut
const intervalId = setInterval(fetchData, intervalMs); const intervalId = setInterval(fetchData, intervalMs);
return () => { mounted = false; clearInterval(intervalId); }; let rtDebounce = null;
}, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours, defilementMode]); const onRtUpdate = () => {
if (!mounted) return;
clearTimeout(rtDebounce);
rtDebounce = setTimeout(() => {
if (mounted) fetchData();
}, 2000);
};
if (typeof window !== 'undefined') {
window.addEventListener('sncf-realtime-updated', onRtUpdate);
}
return () => {
mounted = false;
clearInterval(intervalId);
clearTimeout(rtDebounce);
if (typeof window !== 'undefined') {
window.removeEventListener('sncf-realtime-updated', onRtUpdate);
}
};
}, [gareDisplay, gareParam, gareIdParam, defilementMode]);
// Helpers pour perturbations (memoized plus haut) // Helpers pour perturbations (memoized plus haut)
// Création des lignes vides pour créer l'espace // Création des lignes vides pour créer l'espace
const emptyRows = Array.from({ length: 10 }).map((_, i) => ({ _isEmpty: true, id: `empty-${i}` })); const emptyRows = Array.from({ length: 10 }).map((_, i) => ({ _isEmpty: true, id: `empty-${i}` }));
const maxRowsCount = defilementMode ? 20 : 10;
const limitedHoraires = (horaires || []).slice(0, maxRowsCount);
// Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie) // Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie)
const rowsToRender = defilementMode const rowsToRender = defilementMode
? [...horaires, ...emptyRows, ...horaires, ...emptyRows] ? [...limitedHoraires, ...emptyRows, ...limitedHoraires, ...emptyRows]
: horaires; : limitedHoraires;
// Calcul dynamique de l'animation CSS // Calcul dynamique de l'animation CSS
useEffect(() => { useEffect(() => {
@@ -1238,7 +1287,7 @@ export default function ClassiquesArrivees(){
} }
const horaire = h || {}; const horaire = h || {};
const { logoSrc, typeLabel, numTrain } = getTrainLogoAndMeta(horaire); const { logoSrc, typeLabel, numTrain, isCar: metaIsCar, isSubstitution: metaIsSubstitution, brand: metaBrand, brandClass } = getTrainLogoAndMeta(horaire, gareDisplay || gareParam);
const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || ''); const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || '');
const origin = horaire.gare_depart_nom || horaire.gare_depart || horaire.origine || ''; const origin = horaire.gare_depart_nom || horaire.gare_depart || horaire.origine || '';
const formattedOrigin = formatStationName(origin); const formattedOrigin = formatStationName(origin);
@@ -1259,7 +1308,22 @@ export default function ClassiquesArrivees(){
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 === 'supprimé'; const isSuppressed = status && status.label === 'supprimé';
const isDelayed = status && status.label && String(status.label).startsWith('retardé'); const isDelayed = status && status.label && String(status.label).startsWith('retardé');
const showPlatform = !isSuppressed && Boolean(cleanPlatform); const isSub = metaIsSubstitution || Boolean(horaire.is_substitution);
const isCar = metaIsCar || (
(horaire.type_train && (horaire.type_train.toUpperCase().includes('AUTOCAR') || horaire.type_train.toUpperCase().includes('CAR TER') || horaire.type_train.toUpperCase() === 'BUS')) ||
(horaire.op && (horaire.op.toUpperCase().includes('AUTOCAR') || horaire.op.toUpperCase().includes('CAR TER') || horaire.op.toUpperCase() === 'BUS')) ||
(horaire.type && (horaire.type.toUpperCase().includes('AUTOCAR') || horaire.type.toUpperCase().includes('CAR TER') || horaire.type.toUpperCase() === 'BUS')) ||
Boolean(horaire.is_substitution) ||
(numTrain && String(numTrain).startsWith('3') && String(numTrain).length >= 5) ||
typeLabel === 'AUTOCAR' ||
rawPlatform === 'AUTOCAR' ||
rawPlatform === 'CAR' ||
rawPlatform === 'BUS' ||
cleanPlatform === 'AUTOCAR' ||
cleanPlatform === 'CAR' ||
cleanPlatform === 'BUS'
);
const showPlatform = !isSuppressed && (isCar || Boolean(cleanPlatform));
const stopsList = isExtended ? getStopsListForArrivees(horaire, gareDisplay || gareParam, normalizeName) : []; const stopsList = isExtended ? getStopsListForArrivees(horaire, gareDisplay || gareParam, normalizeName) : [];
@@ -1280,7 +1344,10 @@ export default function ClassiquesArrivees(){
isSuppressed ? ( isSuppressed ? (
<div className="aff-classiques-arrivees__status-suppressed">supprimé</div> <div className="aff-classiques-arrivees__status-suppressed">supprimé</div>
) : isDelayed ? ( ) : isDelayed ? (
<div className="aff-classiques-arrivees__status-delayed">retard {status.delayMinutes} min.</div> <div className="aff-classiques-arrivees__status-delayed">
<div className="aff-classiques-arrivees__status-delayed-label">retardé</div>
<div className="aff-classiques-arrivees__status-delayed-time">+{status.delayMinutes || horaire.retard_minutes || 0} min.</div>
</div>
) : ( ) : (
<div className="aff-classiques-arrivees__status-ontime">à l'heure</div> <div className="aff-classiques-arrivees__status-ontime">à l'heure</div>
) )
@@ -1293,17 +1360,26 @@ export default function ClassiquesArrivees(){
</div> </div>
<div className="aff-classiques-arrivees__col-time"> <div className="aff-classiques-arrivees__col-time">
<div className="aff-classiques-arrivees__time">{time}</div> <div
className="aff-classiques-arrivees__time"
style={{ visibility: isSuppressed && showStatus ? 'hidden' : 'visible' }}
>
{time}
</div>
</div> </div>
<div className="aff-classiques-arrivees__col-destination"> <div className="aff-classiques-arrivees__col-destination">
<MarqueeText text={formattedOrigin} className="aff-classiques-arrivees__destination" /> <MarqueeText text={isSub ? `[CAR DE REMPLACEMENT] ${formattedOrigin}` : formattedOrigin} className="aff-classiques-arrivees__destination" />
</div> </div>
<div className="aff-classiques-arrivees__col-platform"> <div className="aff-classiques-arrivees__col-platform">
{showPlatform ? ( {showPlatform ? (
<div className="aff-classiques-arrivees__platform-box"> <div className="aff-classiques-arrivees__platform-box">
<div className="aff-classiques-arrivees__platform-text">{cleanPlatform}</div> {isCar ? (
<AutocarIcon size="48px" />
) : (
<div className="aff-classiques-arrivees__platform-text">{cleanPlatform}</div>
)}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1314,11 +1390,17 @@ export default function ClassiquesArrivees(){
<div className="aff-classiques-arrivees__sub-stops"> <div className="aff-classiques-arrivees__sub-stops">
<MarqueeText <MarqueeText
className="aff-classiques-arrivees__sub-stops-marquee" className="aff-classiques-arrivees__sub-stops-marquee"
speed={50}
text={ text={
<span className="aff-classiques-arrivees__stops-chain"> <span className="aff-classiques-arrivees__stops-chain">
{stopsList.map((stopName, sIdx) => ( {isSub && (
<span className="aff-classiques-arrivees__stop-item" style={{ color: '#ffd200', fontWeight: 'bold' }}>
CAR DE REMPLACEMENT
<span className="aff-classiques-arrivees__stop-bullet">•</span>
</span>
)}{stopsList.map((stopName, sIdx) => (
<span key={sIdx} className="aff-classiques-arrivees__stop-item"> <span key={sIdx} className="aff-classiques-arrivees__stop-item">
<span className="aff-classiques-arrivees__stop-bullet">•</span> {sIdx > 0 && <span className="aff-classiques-arrivees__stop-bullet">•</span>}
<span className="aff-classiques-arrivees__stop-name">{stopName}</span> <span className="aff-classiques-arrivees__stop-name">{stopName}</span>
</span> </span>
))} ))}
@@ -1345,7 +1427,10 @@ export default function ClassiquesArrivees(){
isSuppressed ? ( isSuppressed ? (
<div className="aff-classiques-arrivees__status-suppressed">supprimé</div> <div className="aff-classiques-arrivees__status-suppressed">supprimé</div>
) : isDelayed ? ( ) : isDelayed ? (
<div className="aff-classiques-arrivees__status-delayed">retard {status.delayMinutes} min.</div> <div className="aff-classiques-arrivees__status-delayed">
<div className="aff-classiques-arrivees__status-delayed-label">retardé</div>
<div className="aff-classiques-arrivees__status-delayed-time">+{status.delayMinutes || horaire.retard_minutes || 0} min.</div>
</div>
) : ( ) : (
<div className="aff-classiques-arrivees__status-ontime">à l'heure</div> <div className="aff-classiques-arrivees__status-ontime">à l'heure</div>
) )
@@ -1359,7 +1444,12 @@ export default function ClassiquesArrivees(){
{/* COL 3 : Heure (grande, jaune) */} {/* COL 3 : Heure (grande, jaune) */}
<div className="aff-classiques-arrivees__col-time"> <div className="aff-classiques-arrivees__col-time">
<div className="aff-classiques-arrivees__time">{time}</div> <div
className="aff-classiques-arrivees__time"
style={{ visibility: isSuppressed && showStatus ? 'hidden' : 'visible' }}
>
{time}
</div>
</div> </div>
{/* COL 4 : Provenance */} {/* COL 4 : Provenance */}
@@ -1371,7 +1461,11 @@ export default function ClassiquesArrivees(){
<div className="aff-classiques-arrivees__col-platform"> <div className="aff-classiques-arrivees__col-platform">
{showPlatform ? ( {showPlatform ? (
<div className="aff-classiques-arrivees__platform-box"> <div className="aff-classiques-arrivees__platform-box">
<div className="aff-classiques-arrivees__platform-text">{cleanPlatform}</div> {isCar ? (
<AutocarIcon size="48px" />
) : (
<div className="aff-classiques-arrivees__platform-text">{cleanPlatform}</div>
)}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -162,7 +162,7 @@
.aff-classiques-departs__stop-bullet { .aff-classiques-departs__stop-bullet {
color: var(--yellow); color: var(--yellow);
font-size: 50px; font-size: 46px;
margin-right: 14px; margin-right: 14px;
margin-left: 18px; margin-left: 18px;
line-height: 1; line-height: 1;
@@ -174,12 +174,12 @@
} }
.aff-classiques-departs__stop-name { .aff-classiques-departs__stop-name {
color: var(--white); color: #d0d8e2;
font-size: 58px; font-size: 62px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
font-family: "Achemine", sans-serif; font-family: "Achemine", sans-serif;
letter-spacing: -0.5px; letter-spacing: -0.3px;
} }
/* Colonne 1 : Logo opérateur (blanc) */ /* Colonne 1 : Logo opérateur (blanc) */
@@ -203,8 +203,8 @@
/* Colonne 2 : Type de train + Numéro OU Statut */ /* Colonne 2 : Type de train + Numéro OU Statut */
.aff-classiques-departs__col-meta { .aff-classiques-departs__col-meta {
width: 185px; width: 200px;
min-width: 185px; min-width: 200px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -217,53 +217,72 @@
} }
.aff-classiques-departs__train-type { .aff-classiques-departs__train-type {
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--white); color: var(--white);
line-height: 1.1; line-height: 1.1;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap; white-space: nowrap;
font-family: "Achemine", sans-serif;
} }
.aff-classiques-departs__train-number { .aff-classiques-departs__train-number {
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--white); color: var(--white);
line-height: 1.1; line-height: 1.1;
margin-top: 4px; margin-top: 4px;
white-space: nowrap; white-space: nowrap;
font-family: "Achemine", sans-serif;
} }
.aff-classiques-departs__status-ontime { .aff-classiques-departs__status-ontime {
color: var(--white); color: var(--white);
font-size: 28px; font-size: 32px;
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.1;
white-space: nowrap; white-space: nowrap;
text-transform: lowercase; text-transform: lowercase;
font-family: "Achemine", sans-serif;
} }
.aff-classiques-departs__status-delayed { .aff-classiques-departs__status-delayed {
color: var(--yellow); color: var(--yellow);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
font-family: "Achemine", sans-serif;
line-height: 1.05;
}
.aff-classiques-departs__status-delayed-label {
font-size: 24px;
font-weight: 700;
color: var(--yellow);
text-transform: lowercase;
}
.aff-classiques-departs__status-delayed-time {
font-size: 26px; font-size: 26px;
font-weight: 700; font-weight: 700;
line-height: 1.1; color: var(--yellow);
white-space: nowrap;
} }
.aff-classiques-departs__status-suppressed { .aff-classiques-departs__status-suppressed {
color: #ff4d4d; color: #ff4d4d;
font-size: 28px; font-size: 32px;
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.1;
white-space: nowrap; white-space: nowrap;
text-transform: lowercase; text-transform: lowercase;
font-family: "Achemine", sans-serif;
} }
/* Colonne 3 : Heure (Grande, jaune vif) */ /* Colonne 3 : Heure (Grande, jaune vif) */
.aff-classiques-departs__col-time { .aff-classiques-departs__col-time {
width: 250px; width: 260px;
min-width: 250px; min-width: 260px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@@ -276,7 +295,7 @@
.aff-classiques-departs__time { .aff-classiques-departs__time {
color: var(--yellow); color: var(--yellow);
font-size: 68px; font-size: 76px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
letter-spacing: -0.5px; letter-spacing: -0.5px;
@@ -302,19 +321,21 @@
.aff-classiques-departs__destination { .aff-classiques-departs__destination {
color: var(--white); color: var(--white);
font-size: 62px; font-size: 70px;
font-weight: 600; font-weight: 700;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-family: "Achemine", sans-serif; font-family: "Achemine", sans-serif;
letter-spacing: -0.4px;
} }
/* Colonne 5 : Quai / Voie (Premier plan absolu z-index: 20, au-dessus du watermark) */ /* Colonne 5 : Quai / Voie (Premier plan absolu z-index: 20, au-dessus du watermark) */
.aff-classiques-departs__col-platform { .aff-classiques-departs__col-platform {
width: 96px; width: 96px;
min-width: 96px; min-width: 96px;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -325,10 +346,10 @@
} }
.aff-classiques-departs__platform-box { .aff-classiques-departs__platform-box {
width: 88px; width: 86px;
height: 84px; height: 86px;
border: 3.5px solid var(--white); border: 3px solid var(--white);
border-radius: 12px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -356,8 +377,8 @@
justify-content: space-between; justify-content: space-between;
box-sizing: border-box; box-sizing: border-box;
padding-left: 24px; padding-left: 24px;
padding-right: 12px; padding-right: 14px;
background: linear-gradient(180deg, #07418a 0%, #00225e 100%) !important; background: linear-gradient(180deg, #093c83 0%, #001f55 100%) !important;
position: relative; position: relative;
z-index: 25; z-index: 25;
border-top: 1.5px solid rgba(255, 255, 255, 0.18); border-top: 1.5px solid rgba(255, 255, 255, 0.18);
@@ -386,23 +407,22 @@
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
border: 2.5px solid rgba(255, 255, 255, 0.85); border: 2.5px solid rgba(255, 255, 255, 0.85);
border-radius: 7px; border-radius: 6px;
padding: 6px 14px 4px 14px; padding: 6px 14px 4px 14px;
background: rgba(0, 20, 60, 0.3); background: rgba(0, 24, 64, 0.45);
font-family: "Achemine", sans-serif; font-family: "Achemine", sans-serif;
color: var(--white);
flex-shrink: 0; flex-shrink: 0;
margin-right: 4px; margin-right: 4px;
box-sizing: border-box; box-sizing: border-box;
height: 76px; height: 74px;
gap: 8px;
} }
.aff-classiques-departs__bottom-clock-hm { .aff-classiques-departs__bottom-clock-hm {
font-size: 54px; font-size: 52px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
letter-spacing: 2px; letter-spacing: 1.5px;
margin-right: 8px;
color: #ffffff; color: #ffffff;
} }
@@ -410,7 +430,9 @@
font-size: 34px; font-size: 34px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
margin-top: 1px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--yellow) !important; color: var(--yellow) !important;
margin-top: 1px;
margin-left: 2px;
} }
@@ -1,3 +1,4 @@
/* eslint-disable */
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import './ClassiquesDeparts.css'; import './ClassiquesDeparts.css';
import supabase from '../../../lib/supabase'; import supabase from '../../../lib/supabase';
@@ -6,9 +7,23 @@ import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';
import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce';
import { fetchAllRows } from '../../../lib/fetchAllRows'; import { fetchAllRows } from '../../../lib/fetchAllRows';
import { getSourceFromPath } from '../../../services/railwayService'; import { getSourceFromPath } from '../../../services/railwayService';
import { getSncfTimetable } from '../../../services/sncfApi'; import { getSncfTimetable, resolveNominalTrack } from '../../../services/sncfApi';
import { matchStationNameStrict } from '../../../services/gtfsStations';
import { getTrainLogoAndMeta } from '../../../utils/trainLogoHelper'; import { getTrainLogoAndMeta } from '../../../utils/trainLogoHelper';
// Pictogramme Autocar SNCF / MOBIGO
const AutocarIcon = ({ size = '50px', color = '#ffffff', style = {} }) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill={color}
style={{ flexShrink: 0, ...style }}
>
<path d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z" />
</svg>
);
// Helper utilitaire : format HHhMM sans secondes (ex: 06h44) // Helper utilitaire : format HHhMM sans secondes (ex: 06h44)
const formatTimeNoSeconds = (v) => { const formatTimeNoSeconds = (v) => {
if (v == null) return ''; if (v == null) return '';
@@ -143,7 +158,7 @@ function Clock(){
); );
} }
export default function ClassiquesDeparts(){ export default function ClassiquesDeparts({ defilement = null } = {}){
// lire le paramètre de requête ?gare=Nom-de-la-gare // lire le paramètre de requête ?gare=Nom-de-la-gare
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const gareParam = params.get('gare'); const gareParam = params.get('gare');
@@ -151,8 +166,8 @@ export default function ClassiquesDeparts(){
const gareIdParam = params.get('gare_id') || params.get('gareId'); const gareIdParam = params.get('gare_id') || params.get('gareId');
// 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;
// Mode défilement (marquee vertical infini) si ?defilement=true dans l'URL // Mode défilement (marquee vertical infini) si ?defilement=true dans l'URL ou passé en prop
const defilementMode = params.get('defilement') === 'true'; const defilementMode = defilement !== null ? Boolean(defilement) : (params.get('defilement') === 'true');
// 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');
@@ -212,12 +227,8 @@ export default function ClassiquesDeparts(){
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle);
if (!gerr && gdata && gdata.length) { if (!gerr && gdata && gdata.length) {
const exactMatch = gdata.find(g => { const exactMatch = gdata.find(g => matchStationNameStrict(g.nom, gareDisplay));
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); const chosen = exactMatch || (gdata.length === 1 ? gdata[0] : null);
const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
return n1 === n2;
});
const chosen = exactMatch || gdata[0];
fetchedType = chosen ? chosen.type : null; fetchedType = chosen ? chosen.type : null;
fetchedId = chosen ? chosen.id : null; fetchedId = chosen ? chosen.id : null;
} }
@@ -619,7 +630,12 @@ export default function ClassiquesDeparts(){
} }
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; if (maxDelay != null) {
const dH = Math.floor(maxDelay / 60);
const dRem = maxDelay % 60;
const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`;
return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails };
}
return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
@@ -650,21 +666,20 @@ export default function ClassiquesDeparts(){
const stopForStation = (stops || []).find(s => { const stopForStation = (stops || []).find(s => {
const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';
const ns = normalizeName(nm || ''); return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && matchStationNameStrict(nm, targetStationName));
const target = normalizeName(targetStationName || '');
return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target);
}) || null; }) || null;
horaire._status = displayStatus; let platformVal = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay || stopForStation.voie)) || horaire.quai || horaire.quai_reel || horaire.platform || horaire.voie || '';
horaire._displayTime = displayTime; if (!platformVal) {
// utiliser quai en priorité platformVal = resolveNominalTrack(targetStationName || gareDisplay, horaire.numero_train || horaire.num, targetStationId || gareIdParam);
horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay)) || horaire.quai || horaire.quai_reel || horaire.platform || ''; }
horaire._displayPlatform = String(platformVal || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
return horaire; return horaire;
}); });
}, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, gareParam, getStops, normalizeName]);
useEffect(() => { useEffect(() => {
// Alternance toutes les 4 secondes (type+numéro ↔ statut) // Alternance toutes les 2 secondes (type+numéro ↔ statut / suppression)
const id = setInterval(() => setShowStatus(s => !s), 4000); const id = setInterval(() => setShowStatus(s => !s), 2000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
@@ -686,12 +701,17 @@ export default function ClassiquesDeparts(){
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
let inFlight = false; // empêche les fetchs concurrents let inFlight = false; // empêche les fetchs concurrents
let pendingRefresh = false;
const source = getSourceFromPath(window.location.pathname); const source = getSourceFromPath(window.location.pathname);
const fetchData = async () => { const fetchData = async () => {
if (!mounted) return; if (!mounted) return;
if (inFlight) return; if (inFlight) {
pendingRefresh = true;
return;
}
inFlight = true; inFlight = true;
pendingRefresh = false;
if (source === 'sncf') { if (source === 'sncf') {
setLoading(true); setLoading(true);
@@ -705,26 +725,36 @@ export default function ClassiquesDeparts(){
if (sncfErr) { if (sncfErr) {
setError(sncfErr); setError(sncfErr);
} else { } else {
const formatted = (sncfData || []).map(t => ({ const formatted = (sncfData || []).map(t => {
...t, const depHM = (typeof t._depHM === 'number') ? t._depHM : parseTimeHM(t.heure_depart);
_displayTime: formatTimeNoSeconds(t.heure_depart), const plat = String(t._displayPlatform || t.quai_reel || t.quai || t.voie || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
_displayPlatform: t.quai || t.voie || '', return {
_depHM: (typeof t._depHM === 'number') ? t._depHM : parseTimeHM(t.heure_depart), ...t,
_status: t._status || { _displayTime: formatTimeNoSeconds(t.heure_depart),
label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"), _displayPlatform: plat,
detail: '', _depHM: depHM,
delayMinutes: t.retard_minutes || null, _status: t._status || {
causeDetails: t.cause_retard || null label: t.etat === 'supprime' ? 'supprimé' : (t.retard_minutes > 0 ? `retardé +${t.retard_minutes}min.` : "à l'heure"),
} detail: '',
})); delayMinutes: t.retard_minutes || null,
setHoraires(formatted); causeDetails: t.cause_retard || null
setAllHoraires(formatted); }
};
});
const maxCount = defilementMode ? 20 : 10;
const limited = (formatted || []).slice(0, maxCount);
setHoraires(limited);
setAllHoraires(limited);
} }
} catch (e) { } catch (e) {
if (mounted) setError(e.message || String(e)); if (mounted) setError(e.message || String(e));
} finally { } finally {
if (mounted) setLoading(false); if (mounted) setLoading(false);
inFlight = false; inFlight = false;
if (pendingRefresh && mounted) {
pendingRefresh = false;
fetchData();
}
} }
return; return;
} }
@@ -788,15 +818,14 @@ export default function ClassiquesDeparts(){
// Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus) // Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus)
let displayHoraires = parsedHoraires.slice(); let displayHoraires = parsedHoraires.slice();
if (gareDisplay) { if (gareDisplay) {
const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));
displayHoraires = displayHoraires.filter(h => { displayHoraires = displayHoraires.filter(h => {
// Exclure si la gare ciblée est la gare d'arrivée finale // Exclure si la gare ciblée est la gare d'arrivée finale
const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); const destName = (h.gare_arrivee_nom || h.gare_arrivee || '').toString();
if (destName === normalized) return false; if (matchStationNameStrict(destName, gareDisplay)) return false;
// Inclure si gare de départ correspond // Inclure si gare de départ correspond
const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString()); const departName = (h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString();
if (departName === normalized) return true; if (matchStationNameStrict(departName, gareDisplay)) return true;
// Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max) // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max)
const stopsList = getStops(h); const stopsList = getStops(h);
@@ -809,11 +838,11 @@ export default function ClassiquesDeparts(){
} }
} }
for (const s of stopsList) { for (const s of stopsList) {
const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); const stopName = (s.gare_nom || s.nom || s.name || s.stop_name || '').toString();
if (!stopName) continue; if (!stopName) continue;
const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx)); const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx));
if (isTerminus) continue; if (isTerminus) continue;
if (stopName === normalized) return true; if (matchStationNameStrict(stopName, gareDisplay)) return true;
} }
} }
return false; return false;
@@ -886,7 +915,7 @@ export default function ClassiquesDeparts(){
const stopsListForH = getStops(h); const stopsListForH = getStops(h);
if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) { if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) {
const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null; const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;
const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; const byName = gareDisplay ? stopsListForH.find(s => matchStationNameStrict(s.gare_nom || s.nom || s.name || '', gareDisplay)) : null;
const stop = byId || byName; const stop = byId || byName;
if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure; if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure;
} }
@@ -1154,18 +1183,40 @@ export default function ClassiquesDeparts(){
const intervalMs = 15000; // 15 secondes par défaut const intervalMs = 15000; // 15 secondes par défaut
const intervalId = setInterval(fetchData, intervalMs); const intervalId = setInterval(fetchData, intervalMs);
return () => { mounted = false; clearInterval(intervalId); }; let rtDebounce = null;
}, [gareDisplay, gareParam, gareIdParam, parseTimeHM, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours, defilementMode]); const onRtUpdate = () => {
if (!mounted) return;
clearTimeout(rtDebounce);
rtDebounce = setTimeout(() => {
if (mounted) fetchData();
}, 2000);
};
if (typeof window !== 'undefined') {
window.addEventListener('sncf-realtime-updated', onRtUpdate);
}
return () => {
mounted = false;
clearInterval(intervalId);
clearTimeout(rtDebounce);
if (typeof window !== 'undefined') {
window.removeEventListener('sncf-realtime-updated', onRtUpdate);
}
};
}, [gareDisplay, gareParam, gareIdParam, defilementMode]);
// Helpers pour perturbations (memoized plus haut) // Helpers pour perturbations (memoized plus haut)
// Création des lignes vides pour créer l'espace // Création des lignes vides pour créer l'espace
const emptyRows = Array.from({ length: 10 }).map((_, i) => ({ _isEmpty: true, id: `empty-${i}` })); const emptyRows = Array.from({ length: 10 }).map((_, i) => ({ _isEmpty: true, id: `empty-${i}` }));
const maxRowsCount = defilementMode ? 20 : 10;
const limitedHoraires = (horaires || []).slice(0, maxRowsCount);
// Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie) // Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie)
const rowsToRender = defilementMode const rowsToRender = defilementMode
? [...horaires, ...emptyRows, ...horaires, ...emptyRows] ? [...limitedHoraires, ...emptyRows, ...limitedHoraires, ...emptyRows]
: horaires; : limitedHoraires;
// Calcul dynamique de l'animation CSS // Calcul dynamique de l'animation CSS
useEffect(() => { useEffect(() => {
@@ -1235,7 +1286,7 @@ export default function ClassiquesDeparts(){
} }
const horaire = h || {}; const horaire = h || {};
const { logoSrc, typeLabel, numTrain } = getTrainLogoAndMeta(horaire); const { logoSrc, typeLabel, numTrain, isCar: metaIsCar, isSubstitution: metaIsSubstitution, brand: metaBrand, brandClass } = getTrainLogoAndMeta(horaire, gareDisplay || gareParam);
const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.departure_time || horaire.time || horaire.heure || ''); const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.departure_time || horaire.time || horaire.heure || '');
const destination = horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || ''; const destination = horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '';
const formattedDestination = formatStationName(destination); const formattedDestination = formatStationName(destination);
@@ -1256,7 +1307,22 @@ export default function ClassiquesDeparts(){
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 === 'supprimé'; const isSuppressed = status && status.label === 'supprimé';
const isDelayed = status && status.label && String(status.label).startsWith('retardé'); const isDelayed = status && status.label && String(status.label).startsWith('retardé');
const showPlatform = !isSuppressed && Boolean(cleanPlatform); const isCar = metaIsCar || (
(horaire.type_train && (horaire.type_train.toUpperCase().includes('AUTOCAR') || horaire.type_train.toUpperCase().includes('CAR TER') || horaire.type_train.toUpperCase() === 'BUS')) ||
(horaire.op && (horaire.op.toUpperCase().includes('AUTOCAR') || horaire.op.toUpperCase().includes('CAR TER') || horaire.op.toUpperCase() === 'BUS')) ||
(horaire.type && (horaire.type.toUpperCase().includes('AUTOCAR') || horaire.type.toUpperCase().includes('CAR TER') || horaire.type.toUpperCase() === 'BUS')) ||
Boolean(horaire.is_substitution) ||
(numTrain && String(numTrain).startsWith('3') && String(numTrain).length >= 5) ||
typeLabel === 'AUTOCAR' ||
rawPlatform === 'AUTOCAR' ||
rawPlatform === 'CAR' ||
rawPlatform === 'BUS' ||
cleanPlatform === 'AUTOCAR' ||
cleanPlatform === 'CAR' ||
cleanPlatform === 'BUS'
);
const isSub = metaIsSubstitution || Boolean(horaire.is_substitution);
const showPlatform = !isSuppressed && (isCar || Boolean(cleanPlatform));
const stopsList = isExtended ? getStopsListForDeparts(horaire, gareDisplay || gareParam, normalizeName) : []; const stopsList = isExtended ? getStopsListForDeparts(horaire, gareDisplay || gareParam, normalizeName) : [];
@@ -1277,7 +1343,10 @@ export default function ClassiquesDeparts(){
isSuppressed ? ( isSuppressed ? (
<div className="aff-classiques-departs__status-suppressed">supprimé</div> <div className="aff-classiques-departs__status-suppressed">supprimé</div>
) : isDelayed ? ( ) : isDelayed ? (
<div className="aff-classiques-departs__status-delayed">retard {status.delayMinutes} min.</div> <div className="aff-classiques-departs__status-delayed">
<div className="aff-classiques-departs__status-delayed-label">retardé</div>
<div className="aff-classiques-departs__status-delayed-time">+{status.delayMinutes || horaire.retard_minutes || 0} min.</div>
</div>
) : ( ) : (
<div className="aff-classiques-departs__status-ontime">à l'heure</div> <div className="aff-classiques-departs__status-ontime">à l'heure</div>
) )
@@ -1290,17 +1359,26 @@ export default function ClassiquesDeparts(){
</div> </div>
<div className="aff-classiques-departs__col-time"> <div className="aff-classiques-departs__col-time">
<div className="aff-classiques-departs__time">{time}</div> <div
className="aff-classiques-departs__time"
style={{ visibility: isSuppressed && showStatus ? 'hidden' : 'visible' }}
>
{time}
</div>
</div> </div>
<div className="aff-classiques-departs__col-destination"> <div className="aff-classiques-departs__col-destination">
<MarqueeText text={formattedDestination} className="aff-classiques-departs__destination" /> <MarqueeText text={isSub ? `[CAR DE REMPLACEMENT] ${formattedDestination}` : formattedDestination} className="aff-classiques-departs__destination" />
</div> </div>
<div className="aff-classiques-departs__col-platform"> <div className="aff-classiques-departs__col-platform">
{showPlatform ? ( {showPlatform ? (
<div className="aff-classiques-departs__platform-box"> <div className="aff-classiques-departs__platform-box">
<div className="aff-classiques-departs__platform-text">{cleanPlatform}</div> {isCar ? (
<AutocarIcon size="48px" />
) : (
<div className="aff-classiques-departs__platform-text">{cleanPlatform}</div>
)}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1311,11 +1389,17 @@ export default function ClassiquesDeparts(){
<div className="aff-classiques-departs__sub-stops"> <div className="aff-classiques-departs__sub-stops">
<MarqueeText <MarqueeText
className="aff-classiques-departs__sub-stops-marquee" className="aff-classiques-departs__sub-stops-marquee"
speed={50}
text={ text={
<span className="aff-classiques-departs__stops-chain"> <span className="aff-classiques-departs__stops-chain">
{stopsList.map((stopName, sIdx) => ( {isSub && (
<span className="aff-classiques-departs__stop-item" style={{ color: '#ffd200', fontWeight: 'bold' }}>
CAR DE REMPLACEMENT
<span className="aff-classiques-departs__stop-bullet">•</span>
</span>
)}{stopsList.map((stopName, sIdx) => (
<span key={sIdx} className="aff-classiques-departs__stop-item"> <span key={sIdx} className="aff-classiques-departs__stop-item">
<span className="aff-classiques-departs__stop-bullet">•</span> {sIdx > 0 && <span className="aff-classiques-departs__stop-bullet">•</span>}
<span className="aff-classiques-departs__stop-name">{stopName}</span> <span className="aff-classiques-departs__stop-name">{stopName}</span>
</span> </span>
))} ))}
@@ -1342,7 +1426,10 @@ export default function ClassiquesDeparts(){
isSuppressed ? ( isSuppressed ? (
<div className="aff-classiques-departs__status-suppressed">supprimé</div> <div className="aff-classiques-departs__status-suppressed">supprimé</div>
) : isDelayed ? ( ) : isDelayed ? (
<div className="aff-classiques-departs__status-delayed">retard {status.delayMinutes} min.</div> <div className="aff-classiques-departs__status-delayed">
<div className="aff-classiques-departs__status-delayed-label">retardé</div>
<div className="aff-classiques-departs__status-delayed-time">+{status.delayMinutes || horaire.retard_minutes || 0} min.</div>
</div>
) : ( ) : (
<div className="aff-classiques-departs__status-ontime">à l'heure</div> <div className="aff-classiques-departs__status-ontime">à l'heure</div>
) )
@@ -1356,7 +1443,12 @@ export default function ClassiquesDeparts(){
{/* COL 3 : Heure (grande, jaune) */} {/* COL 3 : Heure (grande, jaune) */}
<div className="aff-classiques-departs__col-time"> <div className="aff-classiques-departs__col-time">
<div className="aff-classiques-departs__time">{time}</div> <div
className="aff-classiques-departs__time"
style={{ visibility: isSuppressed && showStatus ? 'hidden' : 'visible' }}
>
{time}
</div>
</div> </div>
{/* COL 4 : Destination */} {/* COL 4 : Destination */}
@@ -1368,7 +1460,11 @@ export default function ClassiquesDeparts(){
<div className="aff-classiques-departs__col-platform"> <div className="aff-classiques-departs__col-platform">
{showPlatform ? ( {showPlatform ? (
<div className="aff-classiques-departs__platform-box"> <div className="aff-classiques-departs__platform-box">
<div className="aff-classiques-departs__platform-text">{cleanPlatform}</div> {isCar ? (
<AutocarIcon size="48px" />
) : (
<div className="aff-classiques-departs__platform-text">{cleanPlatform}</div>
)}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -0,0 +1,215 @@
body, html {
margin: 0;
padding: 0;
background-color: #fab23e;
overflow: hidden;
}
/* =========================================================
CONTAINER (1920x1080)
========================================================= */
.aff-classique-flash__container {
position: relative;
width: 1920px;
height: 1080px;
background-color: #fab23e;
color: #00337f;
font-family: 'Achemine', 'Avenir', 'Helvetica Neue', Arial, sans-serif;
overflow: hidden;
box-sizing: border-box;
display: flex;
}
/* =========================================================
SIDEBAR GAUCHE : BLEU NUIT (Conforme Image 3)
========================================================= */
.aff-classique-flash__sidebar {
width: 320px;
height: 1080px;
background-color: #00337f;
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.aff-classique-flash__sidebar-text {
transform: rotate(-90deg);
transform-origin: center center;
white-space: nowrap;
display: flex;
align-items: baseline;
gap: 24px;
user-select: none;
}
.aff-classique-flash__title-flash {
color: #f5a623;
font-size: 10.5rem; /* ~125px */
font-weight: 700;
letter-spacing: 4px;
line-height: 1;
}
.aff-classique-flash__title-circulation {
color: #ffffff;
font-size: 10.5rem; /* ~125px */
font-weight: 700;
letter-spacing: 4px;
line-height: 1;
}
/* =========================================================
ZONE PRINCIPALE : JAUNE / AMBRE (Conforme Image 3)
========================================================= */
.aff-classique-flash__main {
flex: 1;
height: 1080px;
background-color: #fab23e;
position: relative;
box-sizing: border-box;
padding: 60px 80px;
display: flex;
flex-direction: column;
}
.aff-classique-flash__content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 90px;
}
/* AFFICHAGE NOMINAL (TRAFIC NORMAL) */
.aff-classique-flash__nominal {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
max-width: 1300px;
}
.aff-classique-flash__nominal-title {
color: #00337f;
font-size: 6.5rem; /* ~78px */
font-weight: 700;
margin-bottom: 24px;
line-height: 1.1;
text-transform: capitalize;
}
.aff-classique-flash__nominal-subtitle {
color: #00337f;
font-size: 3.8rem; /* ~45px */
font-weight: 500;
line-height: 1.25;
margin-bottom: 24px;
}
.aff-classique-flash__nominal-detail {
color: #00337f;
font-size: 2.8rem; /* ~33px */
font-weight: 400;
line-height: 1.3;
opacity: 0.9;
}
/* CARTES D'ALERTE TRAFIC */
.aff-classique-flash__alerts-container {
display: flex;
flex-direction: column;
gap: 28px;
max-width: 1350px;
}
.aff-classique-flash__alert-card {
background-color: #00337f;
color: #ffffff;
border-radius: 8px;
padding: 32px 40px;
box-shadow: 0 8px 24px rgba(0, 51, 127, 0.2);
}
.aff-classique-flash__alert-card--travaux {
background-color: #1a2332;
}
.aff-classique-flash__alert-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.aff-classique-flash__alert-tag {
background-color: #d32f2f;
color: #ffffff;
font-size: 2rem;
font-weight: 700;
padding: 4px 14px;
border-radius: 4px;
text-transform: uppercase;
}
.aff-classique-flash__alert-tag--travaux {
background-color: #f5a623;
color: #00337f;
}
.aff-classique-flash__alert-line {
font-size: 2.6rem;
font-weight: 700;
color: #fab23e;
}
.aff-classique-flash__alert-title {
font-size: 3.6rem;
font-weight: 700;
margin-bottom: 12px;
line-height: 1.2;
}
.aff-classique-flash__alert-desc {
font-size: 2.8rem;
font-weight: 400;
line-height: 1.35;
color: #e5eaf3;
}
/* =========================================================
HORLOGE BAS DROITE (Conforme Image 3)
========================================================= */
.aff-classique-flash__footer-clock-box {
position: absolute;
bottom: 25px;
right: 35px;
background-color: #00337f;
border: 2.5px solid #ffffff;
border-radius: 4px;
padding: 4px 16px;
display: flex;
align-items: baseline;
z-index: 20;
}
.aff-classique-flash__clock {
display: flex;
align-items: baseline;
gap: 8px;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
font-size: 3.4rem;
font-weight: 700;
line-height: 1;
}
.aff-classique-flash__clock-hm {
color: #ffffff;
}
.aff-classique-flash__clock-s {
color: #f5a623;
}
@@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import './ClassiquesFlashInfo.css';
import useAfficheurData from '../../../hooks/useAfficheurData';
function DigitalClock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return (
<div className="aff-classique-flash__clock">
<span className="aff-classique-flash__clock-hm">{hh}:{mm}</span>
<span className="aff-classique-flash__clock-s">{ss}</span>
</div>
);
}
export default function ClassiquesFlashInfo() {
const params = new URLSearchParams(window.location.search || '');
const gareParam = params.get('gare');
const gareIdParam = params.get('gare_id') || params.get('gareId');
const gareDisplay = gareParam
? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ')
: 'Votre Gare';
const {
perturbations = [],
travaux = []
} = useAfficheurData({
gareName: gareDisplay,
gareId: gareIdParam,
mode: 'departs',
refreshIntervalMs: 20000
});
const activePerturbations = (perturbations || []).filter(p => {
const t = (p.type_perturbation || p.type || '').toString().toLowerCase();
return Boolean(t);
});
const hasTrafficAlerts = activePerturbations.length > 0 || (travaux && travaux.length > 0);
return (
<div className="aff-classique-flash__container">
{/* BANDEAU GAUCHE : BLEU NUIT (Conforme Image 3) */}
<div className="aff-classique-flash__sidebar">
<div className="aff-classique-flash__sidebar-text">
<span className="aff-classique-flash__title-flash">flash</span>
<span className="aff-classique-flash__title-circulation">circulation</span>
</div>
</div>
{/* ZONE PRINCIPALE : AMBRE / JAUNE ORANGE (Conforme Image 3) */}
<div className="aff-classique-flash__main">
<div className="aff-classique-flash__content">
{hasTrafficAlerts ? (
<div className="aff-classique-flash__alerts-container">
{activePerturbations.map((p, idx) => (
<div key={`p-${idx}`} className="aff-classique-flash__alert-card">
<div className="aff-classique-flash__alert-header">
<span className="aff-classique-flash__alert-tag">Info Trafic</span>
<span className="aff-classique-flash__alert-line">{p.ligne_nom || p.ligne || gareDisplay}</span>
</div>
<div className="aff-classique-flash__alert-title">
{p.type_perturbation || p.titre || 'Perturbation en cours'}
</div>
<div className="aff-classique-flash__alert-desc">
{p.message || p.description || p.detail || p.cause || 'Des adaptations de circulation peuvent survenir sur vos trajets. Veuillez écouter les annonces en gare.'}
</div>
</div>
))}
{(travaux || []).slice(0, 2).map((t, idx) => (
<div key={`t-${idx}`} className="aff-classique-flash__alert-card aff-classique-flash__alert-card--travaux">
<div className="aff-classique-flash__alert-header">
<span className="aff-classique-flash__alert-tag aff-classique-flash__alert-tag--travaux">Travaux</span>
<span className="aff-classique-flash__alert-line">{t.nom || t.intitule || 'Maintenance du réseau'}</span>
</div>
<div className="aff-classique-flash__alert-title">
{t.intitule || t.nom || 'Travaux de modernisation'}
</div>
<div className="aff-classique-flash__alert-desc">
{t.description || t.motif || 'Des substitutions par autocars peuvent être mises en place. Merci de vérifier vos horaires.'}
</div>
</div>
))}
</div>
) : (
<div className="aff-classique-flash__nominal">
<div className="aff-classique-flash__nominal-box">
<div className="aff-classique-flash__nominal-title">
Trafic normal
</div>
<div className="aff-classique-flash__nominal-subtitle">
La circulation des trains s'effectue normalement sur l'ensemble des lignes desservant la gare de {gareDisplay}.
</div>
<div className="aff-classique-flash__nominal-detail">
Pour toute information complémentaire concernant vos trajets, nos agents en gare sont à votre disposition.
</div>
</div>
</div>
)}
</div>
{/* HORLOGE EN BAS À DROITE (Conforme Image 3) */}
<div className="aff-classique-flash__footer-clock-box">
<DigitalClock />
</div>
</div>
</div>
);
}
@@ -0,0 +1,133 @@
body, html {
margin: 0;
padding: 0;
background-color: #fab23e;
overflow: hidden;
}
/* =========================================================
CONTAINER (1920x1080)
========================================================= */
.aff-classique-infos__container {
position: relative;
width: 1920px;
height: 1080px;
background-color: #fab23e;
color: #00337f;
font-family: 'Achemine', 'Avenir', 'Helvetica Neue', Arial, sans-serif;
overflow: hidden;
box-sizing: border-box;
display: flex;
user-select: none;
}
/* =========================================================
SIDEBAR GAUCHE : BLEU NUIT (Conforme Image 4)
========================================================= */
.aff-classique-infos__sidebar {
width: 320px;
height: 1080px;
background-color: #00337f;
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.aff-classique-infos__sidebar-text {
transform: rotate(-90deg);
transform-origin: center center;
white-space: nowrap;
user-select: none;
}
.aff-classique-infos__title-information {
color: #ffffff;
font-size: 10.5rem; /* ~125px */
font-weight: 700;
letter-spacing: 4px;
line-height: 1;
}
/* =========================================================
ZONE PRINCIPALE : CADRE JAUNE DE DROITE
========================================================= */
.aff-classique-infos__main {
flex: 1;
height: 1080px;
background-color: #fab23e;
position: relative;
box-sizing: border-box;
padding: 80px 100px;
display: flex;
flex-direction: column;
justify-content: center;
}
/* CONTENEUR DU MESSAGE UNIQUE (TEXTE BLEU SUR FOND JAUNE) */
.aff-classique-infos__single-message-container {
max-width: 1350px;
display: flex;
flex-direction: column;
gap: 36px;
padding-bottom: 90px;
}
.aff-classique-infos__single-title {
color: #00337f;
font-size: 64px;
font-weight: 900;
line-height: 1.15;
margin: 0;
letter-spacing: -0.5px;
text-transform: none;
}
.aff-classique-infos__single-body {
color: #00337f;
font-size: 46px;
font-weight: 600;
line-height: 1.45;
margin: 0;
max-width: 1250px;
}
/* =========================================================
HORLOGE DIGITALE EN BAS À DROITE (Conforme Image 4)
========================================================= */
.aff-classique-infos__footer-clock-box {
position: absolute;
bottom: 40px;
right: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.aff-classique-infos__clock {
display: inline-flex;
align-items: baseline;
background-color: #00337f;
border: 4px solid #ffffff;
border-radius: 8px;
padding: 10px 22px;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.aff-classique-infos__clock-hm {
font-size: 3.4rem; /* ~54px */
font-weight: 800;
color: #ffffff;
letter-spacing: 1px;
}
.aff-classique-infos__clock-s {
font-size: 2.2rem; /* ~35px */
font-weight: 800;
color: #ff9e1b;
letter-spacing: 0.5px;
}
@@ -0,0 +1,121 @@
/* eslint-disable */
import React, { useState, useEffect } from 'react';
import './ClassiquesInformations.css';
import supabase from '../../../lib/supabase';
function DigitalClock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return (
<div className="aff-classique-infos__clock">
<span className="aff-classique-infos__clock-hm">{hh}:{mm}</span>
<span className="aff-classique-infos__clock-s">{ss}</span>
</div>
);
}
export default function ClassiquesInformations() {
const params = new URLSearchParams(window.location.search || '');
const gareParam = params.get('gare');
const gareDisplay = gareParam
? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ')
: 'Votre Gare';
const defaultMessages = [
{
title: 'Plan Vigipirate & Sécurité',
content: 'Ne laissez aucun bagage sans surveillance. Tout bagage abandonné sera détruit. Signalez tout colis ou comportement suspect aux agents SNCF ou aux forces de l\'ordre.'
},
{
title: 'Étiquetage obligatoire de vos bagages',
content: 'Pour votre tranquillité et la sécurité de tous, vos valises et sacs doivent impérativement comporter une étiquette visible indiquant vos nom, prénom, téléphone et adresse.'
},
{
title: 'Traversée des voies formellement interdite',
content: 'Pour votre sécurité, il est strictement interdit de descendre sur les voies ou de les traverser. Utilisez systématiquement les passages souterrains ou les passerelles.'
},
{
title: 'Espace sans tabac ni vapotage',
content: 'Conformément à la réglementation, il est strictement interdit de fumer ou de vapoter dans les espaces couverts, les bâtiments de la gare ainsi que sur les quais.'
}
];
const [messagesList, setMessagesList] = useState(defaultMessages);
const [activeMessageIndex, setActiveMessageIndex] = useState(0);
// Chargement éventuel de messages dynamiques depuis Supabase
useEffect(() => {
let mounted = true;
const fetchStationMessages = async () => {
if (!supabase) return;
try {
const { data, error } = await supabase
.from('messages_dynamiques')
.select('*')
.order('id', { ascending: false });
if (!error && Array.isArray(data) && data.length > 0 && mounted) {
const formatted = data.map(m => ({
title: m.titre || m.title || 'Information Voyageurs',
content: m.contenu || m.content || m.message || ''
})).filter(m => Boolean(m.content));
if (formatted.length > 0) {
setMessagesList(formatted);
}
}
} catch (e) {
console.warn('Notice chargement messages_dynamiques:', e);
}
};
fetchStationMessages();
return () => { mounted = false; };
}, []);
// Défilement d'un seul message à la fois toutes les 10s si plusieurs messages
useEffect(() => {
if (messagesList.length <= 1) return;
const timer = setInterval(() => {
setActiveMessageIndex(prev => (prev + 1) % messagesList.length);
}, 10000);
return () => clearInterval(timer);
}, [messagesList.length]);
const activeMsg = messagesList[activeMessageIndex % messagesList.length] || defaultMessages[0];
return (
<div className="aff-classique-infos__container">
{/* BANDEAU GAUCHE : BLEU NUIT (Conforme Image 4) */}
<div className="aff-classique-infos__sidebar">
<div className="aff-classique-infos__sidebar-text">
<span className="aff-classique-infos__title-information">information</span>
</div>
</div>
{/* ZONE PRINCIPALE : CADRE JAUNE DE DROITE AVEC TEXTE BLEU (Conforme Image 4) */}
<div className="aff-classique-infos__main">
<div className="aff-classique-infos__single-message-container">
{activeMsg.title && (
<h1 className="aff-classique-infos__single-title">
{activeMsg.title}
</h1>
)}
<p className="aff-classique-infos__single-body">
{activeMsg.content}
</p>
</div>
{/* HORLOGE EN BAS À DROITE */}
<div className="aff-classique-infos__footer-clock-box">
<DigitalClock />
</div>
</div>
</div>
);
}
@@ -1,337 +1,415 @@
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
body, html { body, html {
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #006330; /* Green theme */ background-color: #116938;
overflow: hidden; overflow: hidden;
} }
.aff-quais-arrivees-redesign__container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
position: relative;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
background-color: #006330;
color: #fff;
overflow: hidden;
}
.aff-quais-arrivees-redesign__empty {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.aff-quais-arrivees-redesign__empty-text {
font-size: 6rem;
font-weight: 700;
color: #ffffff;
text-align: center;
}
/* ========================================================= /* =========================================================
LEFT PANEL CONTAINER (1920x1080)
========================================================= */ ========================================================= */
.aff-quais-arrivees-redesign__left { .aff-classique-quai-arr__container {
position: relative;
width: 1920px;
height: 1080px;
background-color: #116938;
color: #ffffff;
font-family: 'Achemine', 'Avenir', 'Helvetica Neue', Arial, sans-serif;
overflow: hidden;
box-sizing: border-box;
}
/* =========================================================
LEFT PANEL (BLANC - Conforme Image 2)
========================================================= */
.aff-classique-quai-arr__left {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 35%; width: 672px;
bottom: 80px; bottom: 85px;
background-color: #f3eef4; /* Lilac/Light Grey background */ background-color: #ffffff;
box-sizing: border-box; box-sizing: border-box;
padding: 30px 40px; padding: 42px 48px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
}
.aff-quais-arrivees-redesign__sncf-logo {
width: 90px;
height: auto;
margin-bottom: 25px;
z-index: 2; z-index: 2;
} }
.aff-quais-arrivees-redesign__time-status { .aff-classique-quai-arr__left--tgv {
bottom: 240px !important;
padding: 32px 48px 20px 48px !important;
}
/* Logo Dynamique (TER Bleu / Opérateur) */
.aff-classique-quai-arr__brand {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 24px;
z-index: 3;
}
.aff-classique-quai-arr__brand-logo {
height: 50px;
max-width: 220px;
object-fit: contain;
display: block;
}
.aff-classique-quai-arr__sncf-voyageurs {
font-size: 14px;
font-weight: 800;
color: #116938;
letter-spacing: 2px;
margin-top: 4px;
}
/* Ligne Heure + Statut */
.aff-classique-quai-arr__time-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 15px; min-height: 100px;
margin-bottom: 5px; margin-bottom: 8px;
z-index: 2; z-index: 3;
} }
.aff-quais-arrivees-redesign__time { .aff-classique-quai-arr__time {
color: #006330; /* Green theme text */ color: #116938;
font-weight: 700; font-weight: 700;
font-size: 7.5rem; font-size: 105px;
line-height: 0.92;
letter-spacing: -1px;
}
.aff-classique-quai-arr__status {
color: #116938;
font-weight: 700;
font-size: 32px;
line-height: 1; line-height: 1;
margin-left: 20px;
} }
.aff-quais-arrivees-redesign__status { .aff-classique-quai-arr__status-alt {
color: #006330; /* Green theme status */
font-weight: 700; font-weight: 700;
font-size: 2.4rem; font-size: 68px;
line-height: 1; line-height: 1;
letter-spacing: -0.5px;
white-space: nowrap;
} }
.aff-quais-arrivees-redesign__destination { .aff-classique-quai-arr__status--suppressed {
color: #006330; /* Green theme destination (origin) */ color: #d32f2f !important;
}
.aff-classique-quai-arr__status--delayed {
color: #f5a623 !important;
}
/* Grande Destination / Gare Terminus */
.aff-classique-quai-arr__destination {
color: #116938;
font-weight: 700; font-weight: 700;
font-size: 5.5rem; /* Slightly smaller to fit 2 lines if needed */ font-size: 82px;
line-height: 1.1; line-height: 1.05;
margin-bottom: 5px; margin-top: 14px;
z-index: 2; margin-bottom: 14px;
white-space: normal; /* Allow wrapping */ z-index: 3;
display: -webkit-box; word-break: break-word;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
max-height: 13rem; /* Prevent overflowing panel */
} }
.aff-quais-arrivees-redesign__train-info { .aff-classique-quai-arr__destination--multi {
font-size: 52px !important;
margin-top: 6px !important;
margin-bottom: 4px !important;
}
/* Services PMR, Vélo, Bar */
.aff-classique-quai-arr__services {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 18px;
z-index: 2; margin-top: auto;
margin-bottom: 8px;
z-index: 4;
} }
.aff-quais-arrivees-redesign__train-type { /* Info Train (Type + Numéro) */
color: #006330; /* Green theme train type */ .aff-classique-quai-arr__train-info {
display: flex;
align-items: baseline;
z-index: 3;
}
.aff-classique-quai-arr__train-type {
color: #116938;
font-weight: 700; font-weight: 700;
font-size: 2.2rem; font-size: 38px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.aff-quais-arrivees-redesign__train-number { .aff-classique-quai-arr__train-number {
color: #7e8a80; /* Grey/Green train number */ color: #7e859b;
font-weight: 400; font-weight: 400;
font-size: 2.2rem; font-size: 38px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-left: 12px;
} }
.aff-quais-arrivees-redesign__icons { /* Filigrane Vertical "arrivée" géant (orienté bas vers haut) */
.aff-classique-quai-arr__watermark {
position: absolute; position: absolute;
right: -5px;
bottom: 20px; bottom: 20px;
left: 40px; width: 0;
height: 0;
display: flex; display: flex;
align-items: center; justify-content: flex-start;
z-index: 2; align-items: flex-end;
}
.aff-quais-arrivees-redesign__watermark {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%) rotate(-90deg);
transform-origin: right center;
font-size: 17rem; /* Massive size */
color: #dbebdf; /* Light green/grey watermark */
font-weight: 700;
z-index: 1;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
letter-spacing: 1.5rem; z-index: 1;
}
.aff-classique-quai-arr__watermark span {
display: inline-block;
transform: rotate(-90deg);
transform-origin: 0 100%;
font-size: 168px;
font-weight: 700;
color: #d5e5d8;
line-height: 1; line-height: 1;
letter-spacing: 0.05em;
white-space: nowrap; white-space: nowrap;
} }
/* ========================================================= /* =========================================================
RIGHT PANEL RIGHT PANEL (VERT FORÊT - Conforme Image 2)
========================================================= */ ========================================================= */
.aff-quais-arrivees-redesign__right { .aff-classique-quai-arr__right {
position: absolute; position: absolute;
top: 0; top: 0;
left: 672px;
right: 0; right: 0;
width: 65%; bottom: 85px;
bottom: 80px; background-color: #116938;
background-color: #006330; /* Green theme */
box-sizing: border-box; box-sizing: border-box;
padding: 30px 0 30px 0; padding: 42px 0 20px 42px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.aff-quais-arrivees-redesign__provenance-title { .aff-classique-quai-arr__right--tgv {
font-size: 3.4rem; bottom: 240px !important;
color: #dbeb1a; /* Yellow provenance header */
margin-left: 60px;
margin-bottom: 20px;
font-weight: 700;
} }
.aff-quais-arrivees-redesign__stops-list { .aff-classique-quai-arr__stops-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.aff-classique-quai-arr__stops-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0 0 0 60px; /* Space from left edge */ padding: 0;
width: 100%;
position: relative;
z-index: 2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; /* Closer spacing between stops */ gap: 12px;
position: relative;
z-index: 2;
} }
.aff-quais-arrivees-redesign__stop { .aff-classique-quai-arr__stop-item {
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 3.4rem;
font-weight: 400;
color: #ffffff;
position: relative; position: relative;
min-height: 42px; min-height: 56px;
padding-left: 50px; /* Space for rail and bullet */ padding-left: 65px;
} }
/* Bullet styling */ /* Rail vertical vert sauge (#5c8b6b) */
.aff-quais-arrivees-redesign__stop-bullet { .aff-classique-quai-arr__stop-item::before {
content: '';
position: absolute; position: absolute;
left: 0; /* Centered on 24px rail: (24-16)/2 = 4 */ left: 20px;
margin-left: 4px; width: 28px;
top: 50%; background-color: #5c8b6b;
transform: translateY(-50%); top: -15px;
width: 16px; /* Slightly larger bullet */ bottom: -15px;
height: 16px; z-index: 1;
background-color: #fbec22; }
/* Le rail monte jusqu'en haut au 1er arrêt */
.aff-classique-quai-arr__stop-item:first-child::before {
top: -600px;
}
/* Le dernier arrêt n'a pas de rail qui continue en dessous */
.aff-classique-quai-arr__stop-item:last-child::before {
display: none;
}
/* Pastille Jaune (#ffe500 / #fbec22) */
.aff-classique-quai-arr__bullet {
position: absolute;
left: 24px;
width: 20px;
height: 20px;
background-color: #ffe500;
border-radius: 50%; border-radius: 50%;
z-index: 4; z-index: 4;
} }
.aff-quais-arrivees-redesign__stop-text { /* Nom de la station */
position: relative; .aff-classique-quai-arr__stop-name {
z-index: 4; font-size: 42px;
font-weight: 600;
color: #ffffff;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding-left: 10px; /* Space from bullet */ z-index: 4;
} }
/* The vertical rail segments drawn behind each bullet */ /* Gare terminus / mise en avant */
.aff-quais-arrivees-redesign__stop::before { .aff-classique-quai-arr__stop-item--dest {
content: ''; font-weight: 700;
position: absolute;
left: 0;
width: 24px; /* Much wider rail */
background-color: #8fa993; /* Light green-grey rail color */
top: -10px;
bottom: -10px;
z-index: 1;
} }
/* Rail starts at the first bullet's center */ .aff-classique-quai-arr__stop-item--dest .aff-classique-quai-arr__stop-name {
.aff-quais-arrivees-redesign__stop:first-child::before { background-color: #5c8b6b;
top: 50%; padding: 8px 32px 8px 65px;
} margin-left: -65px;
border-radius: 6px;
/* Last stop has no rail continuing below */ position: relative;
.aff-quais-arrivees-redesign__stop:last-child::before { z-index: 3;
bottom: 50%; display: inline-block;
font-weight: 700;
font-size: 44px;
} }
/* ========================================================= /* =========================================================
FOOTER FOURCHE / BIFURCATION DESSERTE TGV (Conforme Image 2)
========================================================= */ ========================================================= */
.aff-quais-arrivees-redesign__footer { .aff-classique-quai-arr__fork-container {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
}
.aff-classique-quai-arr__fork-split-row {
position: relative;
display: flex;
align-items: flex-start;
min-height: 100px;
padding-left: 65px;
margin-top: 4px;
}
/* Rail courbe violet (#5c538f) */
.aff-classique-quai-arr__fork-curve-rail {
position: absolute;
left: 20px;
top: -15px;
width: 125px;
height: 80px;
border-left: 28px solid #5c538f;
border-bottom: 28px solid #5c538f;
border-bottom-left-radius: 35px;
z-index: 1;
pointer-events: none;
}
/* Branche droite */
.aff-classique-quai-arr__fork-branch-right {
display: flex;
flex-direction: column;
margin-left: 115px;
margin-top: 10px;
z-index: 3;
}
/* Libellé "voitures 11 à 18" */
.aff-classique-quai-arr__fork-coaches-label {
color: #f5a623;
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
letter-spacing: 0.5px;
}
/* Boîte terminus branche droite */
.aff-classique-quai-arr__fork-terminus-box {
background-color: #5c538f;
padding: 8px 30px 8px 20px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 16px;
}
.aff-classique-quai-arr__fork-terminus-name {
color: #ffffff;
font-size: 44px;
font-weight: 700;
white-space: nowrap;
}
/* =========================================================
FOOTER & HORLOGE (Conforme Image 2)
========================================================= */
.aff-classique-quai-arr__footer {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 1920px;
height: 80px; height: 85px;
background-color: #006330; /* Green theme */ background-color: #116938;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
padding: 0 40px; padding: 0 35px;
z-index: 10; z-index: 20;
} }
.aff-quais-arrivees-redesign__footer-left { .aff-classique-quai-arr__footer-track {
font-size: 3rem; font-size: 34px;
font-weight: 700; font-weight: 700;
color: #ffffff; color: #ffffff;
font-style: italic; font-style: italic;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.aff-quais-arrivees-redesign__footer-right { .aff-classique-quai-arr__footer-clock {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.aff-quais-arrivees-redesign__clock { .aff-classique-quai-arr__clock {
border: 2px solid #ffffff; background-color: #00337f;
padding: 4px 14px; border: 2.5px solid #ffffff;
font-size: 3rem; padding: 4px 16px;
font-weight: 700; border-radius: 3px;
color: #ffffff;
display: flex; display: flex;
align-items: center; align-items: baseline;
gap: 10px; gap: 4px;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
font-size: 42px;
font-weight: 700;
line-height: 1;
} }
.aff-quais-arrivees-redesign__clock-s { .aff-classique-quai-arr__clock-hm {
color: #f5a623; color: #ffffff;
} }
.aff-quais-arrivees-redesign__time--long-text { .aff-classique-quai-arr__clock-s {
font-size: 3.5rem !important; color: #ff8c00;
line-height: 1.2 !important; margin-left: 2px;
} }
.aff-quais-arrivees-redesign__time--suppressed,
.aff-quais-arrivees-redesign__status--suppressed {
color: #ff4c5b !important;
}
.aff-quais-arrivees-redesign__time--delayed,
.aff-quais-arrivees-redesign__status--delayed {
color: #ff9e43 !important;
}
/* Disrupted Stop styling */
.aff-quais-arrivees-redesign__stop-text--suppressed {
text-decoration: line-through;
color: #ff4c5b;
opacity: 0.6;
}
.aff-quais-arrivees-redesign__stop-text--delayed {
color: #ff9e43;
}
.aff-quais-arrivees-redesign__stop-status-badge {
font-size: 2.2rem;
font-weight: bold;
margin-left: 10px;
}
.aff-quais-arrivees-redesign__stop-status-badge--suppressed {
color: #ff4c5b;
}
.aff-quais-arrivees-redesign__stop-status-badge--delayed {
color: #ff9e43;
}
File diff suppressed because it is too large. Load diff
@@ -1,344 +1,415 @@
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Achemine';
src: url('/public/fonts/achemine_bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
body, html { body, html {
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #0b376d; background-color: #00337f;
overflow: hidden; overflow: hidden;
} }
.aff-quais-departs-redesign__container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
position: relative;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
background-color: #0b376d;
color: #fff;
overflow: hidden;
}
.aff-quais-departs-redesign__empty {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.aff-quais-departs-redesign__empty-text {
font-size: 6rem;
font-weight: 700;
color: #ffffff;
text-align: center;
}
/* ========================================================= /* =========================================================
LEFT PANEL CONTAINER (1920x1080)
========================================================= */ ========================================================= */
.aff-quais-departs-redesign__left { .aff-classique-quai-dep__container {
position: relative;
width: 1920px;
height: 1080px;
background-color: #00337f;
color: #ffffff;
font-family: 'Achemine', 'Avenir', 'Helvetica Neue', Arial, sans-serif;
overflow: hidden;
box-sizing: border-box;
}
/* =========================================================
LEFT PANEL (BLANC - Conforme Image 1)
========================================================= */
.aff-classique-quai-dep__left {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 35%; width: 672px;
bottom: 80px; bottom: 85px;
background-color: #ffffff; background-color: #ffffff;
box-sizing: border-box; box-sizing: border-box;
padding: 30px 40px; padding: 42px 48px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
}
.aff-quais-departs-redesign__sncf-logo {
width: 90px;
height: auto;
margin-bottom: 25px;
z-index: 2; z-index: 2;
} }
.aff-quais-departs-redesign__time-status { .aff-classique-quai-dep__left--tgv {
bottom: 240px !important;
padding: 32px 48px 20px 48px !important;
}
/* Logo Dynamique */
.aff-classique-quai-dep__brand {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 24px;
z-index: 3;
}
.aff-classique-quai-dep__brand-logo {
height: 50px;
max-width: 220px;
object-fit: contain;
display: block;
}
.aff-classique-quai-dep__sncf-voyageurs {
font-size: 14px;
font-weight: 800;
color: #00337f;
letter-spacing: 2px;
margin-top: 4px;
}
/* Ligne Heure + Statut */
.aff-classique-quai-dep__time-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 15px; min-height: 100px;
margin-bottom: 5px; margin-bottom: 8px;
z-index: 2; z-index: 3;
} }
.aff-quais-departs-redesign__time { .aff-classique-quai-dep__time {
color: #0b376d; color: #00337f;
font-weight: 700; font-weight: 700;
font-size: 7.5rem; font-size: 105px;
line-height: 0.92;
letter-spacing: -1px;
}
.aff-classique-quai-dep__status {
color: #00337f;
font-weight: 700;
font-size: 32px;
line-height: 1; line-height: 1;
margin-left: 20px;
} }
.aff-quais-departs-redesign__status { .aff-classique-quai-dep__status-alt {
color: #0b376d;
font-weight: 700; font-weight: 700;
font-size: 2.4rem; font-size: 68px;
line-height: 1; line-height: 1;
letter-spacing: -0.5px;
white-space: nowrap;
} }
.aff-quais-departs-redesign__destination { .aff-classique-quai-dep__status--suppressed {
color: #0b376d; color: #d32f2f !important;
}
.aff-classique-quai-dep__status--delayed {
color: #f5a623 !important;
}
/* Grande Destination */
.aff-classique-quai-dep__destination {
color: #00337f;
font-weight: 700; font-weight: 700;
font-size: 5.5rem; /* Slightly smaller to fit 2 lines if needed */ font-size: 82px;
line-height: 1.1; line-height: 1.05;
margin-bottom: 5px; margin-top: 14px;
z-index: 2; margin-bottom: 14px;
white-space: normal; /* Allow wrapping */ z-index: 3;
display: -webkit-box; word-break: break-word;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
max-height: 13rem; /* Prevent overflowing panel */
} }
.aff-quais-departs-redesign__train-info { .aff-classique-quai-dep__destination--multi {
font-size: 52px !important;
margin-top: 6px !important;
margin-bottom: 4px !important;
}
/* Services PMR, Vélo, Bar */
.aff-classique-quai-dep__services {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 18px;
z-index: 2; margin-top: auto;
margin-bottom: 8px;
z-index: 4;
} }
.aff-quais-departs-redesign__train-type { /* Info Train (Type + Numéro) */
color: #0b376d; .aff-classique-quai-dep__train-info {
display: flex;
align-items: baseline;
z-index: 3;
}
.aff-classique-quai-dep__train-type {
color: #00337f;
font-weight: 700; font-weight: 700;
font-size: 2.2rem; font-size: 38px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.aff-quais-departs-redesign__train-number { .aff-classique-quai-dep__train-number {
color: #929bb0; color: #7e859b;
font-weight: 400; font-weight: 400;
font-size: 2.2rem; font-size: 38px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-left: 12px;
} }
.aff-quais-departs-redesign__icons { /* Filigrane Vertical "départ" géant (orienté bas vers haut) */
.aff-classique-quai-dep__watermark {
position: absolute; position: absolute;
right: -5px;
bottom: 20px; bottom: 20px;
left: 40px; width: 0;
height: 0;
display: flex; display: flex;
align-items: center; justify-content: flex-start;
z-index: 2; align-items: flex-end;
}
.aff-quais-departs-redesign__watermark {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%) rotate(-90deg);
transform-origin: right center;
font-size: 17rem; /* Massive size */
color: #e5e8ed;
font-weight: 700;
z-index: 1;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
letter-spacing: 1.5rem; z-index: 1;
}
.aff-classique-quai-dep__watermark span {
display: inline-block;
transform: rotate(-90deg);
transform-origin: 0 100%;
font-size: 168px;
font-weight: 700;
color: #e2e8f2;
line-height: 1; line-height: 1;
letter-spacing: 0.05em;
white-space: nowrap; white-space: nowrap;
} }
/* ========================================================= /* =========================================================
RIGHT PANEL RIGHT PANEL (BLEU NUIT - Conforme Image 1)
========================================================= */ ========================================================= */
.aff-quais-departs-redesign__right { .aff-classique-quai-dep__right {
position: absolute; position: absolute;
top: 0; top: 0;
left: 672px;
right: 0; right: 0;
width: 65%; bottom: 85px;
bottom: 80px; background-color: #00337f;
background-color: #0b376d;
box-sizing: border-box; box-sizing: border-box;
padding: 30px 0 30px 0; padding: 42px 0 20px 42px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.aff-quais-departs-redesign__stops-list { .aff-classique-quai-dep__right--tgv {
bottom: 240px !important;
}
.aff-classique-quai-dep__stops-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.aff-classique-quai-dep__stops-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0 0 0 60px; /* Space from left edge */ padding: 0;
width: 100%;
position: relative;
z-index: 2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; /* Closer spacing between stops */ gap: 12px;
position: relative;
z-index: 2;
} }
.aff-quais-departs-redesign__stop { .aff-classique-quai-dep__stop-item {
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 3.4rem;
font-weight: 400;
color: #ffffff;
position: relative; position: relative;
min-height: 42px; min-height: 56px;
padding-left: 50px; /* Space for rail and bullet */ padding-left: 65px;
} }
/* Bullet styling */ /* Rail vertical violet/indigo (#5c538f) */
.aff-quais-departs-redesign__stop-bullet { .aff-classique-quai-dep__stop-item::before {
content: '';
position: absolute; position: absolute;
left: 0; /* Centered on 24px rail: (24-16)/2 = 4 */ left: 20px;
margin-left: 4px; width: 28px;
top: 50%; background-color: #5c538f;
transform: translateY(-50%); top: -15px;
width: 16px; /* Slightly larger bullet */ bottom: -15px;
height: 16px; z-index: 1;
background-color: #fbec22; }
/* Le rail monte jusqu'en haut au 1er arrêt */
.aff-classique-quai-dep__stop-item:first-child::before {
top: -600px;
}
/* Le dernier arrêt n'a pas de rail qui continue en dessous */
.aff-classique-quai-dep__stop-item:last-child::before {
display: none;
}
/* Pastille Jaune (#ffe500 / #fbec22) */
.aff-classique-quai-dep__bullet {
position: absolute;
left: 24px;
width: 20px;
height: 20px;
background-color: #ffe500;
border-radius: 50%; border-radius: 50%;
z-index: 4; z-index: 4;
} }
.aff-quais-departs-redesign__stop-text { /* Nom de la station */
position: relative; .aff-classique-quai-dep__stop-name {
z-index: 4; font-size: 42px;
font-weight: 600;
color: #ffffff;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding-left: 10px; /* Space from bullet */ z-index: 4;
} }
/* The vertical rail segments drawn behind each bullet */ /* Gare terminus / mise en avant */
.aff-quais-departs-redesign__stop::before { .aff-classique-quai-dep__stop-item--dest {
content: '';
position: absolute;
left: 0;
width: 24px; /* Much wider rail */
background-color: #5c538f;
top: -10px;
bottom: -10px;
z-index: 1;
}
/* Rail extends all the way to the top */
.aff-quais-departs-redesign__stop:first-child::before {
top: -1000px;
}
/* Last stop has no rail continuing below */
.aff-quais-departs-redesign__stop:last-child::before {
display: none;
}
/* Highlighted Destination Stop */
.aff-quais-departs-redesign__stop--dest {
font-weight: 700; font-weight: 700;
} }
.aff-quais-departs-redesign__stop--dest .aff-quais-departs-redesign__stop-text { .aff-classique-quai-dep__stop-item--dest .aff-classique-quai-dep__stop-name {
background-color: #5c538f; background-color: #5c538f;
padding: 4px 20px 4px 60px; /* Increased left padding to cover bullet */ padding: 8px 32px 8px 65px;
margin-left: -60px; /* Pull back to exactly align with left:0 */ margin-left: -65px;
border-radius: 4px; border-radius: 6px;
position: relative; position: relative;
z-index: 3; /* Behind the bullet (z-index 4) */ z-index: 3;
display: inline-block; display: inline-block;
font-weight: 700;
font-size: 44px;
} }
/* ========================================================= /* =========================================================
FOOTER FOURCHE / BIFURCATION DESSERTE TGV (Conforme Image 2)
========================================================= */ ========================================================= */
.aff-quais-departs-redesign__footer { .aff-classique-quai-dep__fork-container {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
}
.aff-classique-quai-dep__fork-split-row {
position: relative;
display: flex;
align-items: flex-start;
min-height: 100px;
padding-left: 65px;
margin-top: 4px;
}
/* Rail courbe violet (#5c538f) */
.aff-classique-quai-dep__fork-curve-rail {
position: absolute;
left: 20px;
top: -15px;
width: 125px;
height: 80px;
border-left: 28px solid #5c538f;
border-bottom: 28px solid #5c538f;
border-bottom-left-radius: 35px;
z-index: 1;
pointer-events: none;
}
/* Branche droite */
.aff-classique-quai-dep__fork-branch-right {
display: flex;
flex-direction: column;
margin-left: 115px;
margin-top: 10px;
z-index: 3;
}
/* Libellé "voitures 11 à 18" */
.aff-classique-quai-dep__fork-coaches-label {
color: #f5a623;
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
letter-spacing: 0.5px;
}
/* Boîte terminus branche droite (ex: Lille Europe) */
.aff-classique-quai-dep__fork-terminus-box {
background-color: #5c538f;
padding: 8px 30px 8px 20px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 16px;
}
.aff-classique-quai-dep__fork-terminus-name {
color: #ffffff;
font-size: 44px;
font-weight: 700;
white-space: nowrap;
}
/* =========================================================
FOOTER & HORLOGE (Conforme Image 1)
========================================================= */
.aff-classique-quai-dep__footer {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 1920px;
height: 80px; height: 85px;
background-color: #0b376d; background-color: #00337f;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
padding: 0 40px; padding: 0 35px;
z-index: 10; z-index: 20;
} }
.aff-quais-departs-redesign__footer-left { .aff-classique-quai-dep__footer-track {
font-size: 3rem; font-size: 34px;
font-weight: 700; font-weight: 700;
color: #ffffff; color: #ffffff;
font-style: italic; font-style: italic;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.aff-quais-departs-redesign__footer-right { .aff-classique-quai-dep__footer-clock {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.aff-quais-departs-redesign__clock { .aff-classique-quai-dep__clock {
border: 2px solid #ffffff; background-color: #00337f;
padding: 4px 14px; border: 2.5px solid #ffffff;
font-size: 3rem; padding: 4px 16px;
font-weight: 700; border-radius: 3px;
color: #ffffff;
display: flex; display: flex;
align-items: center; align-items: baseline;
gap: 10px; gap: 4px;
font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;
font-size: 42px;
font-weight: 700;
line-height: 1;
} }
.aff-quais-departs-redesign__clock-s { .aff-classique-quai-dep__clock-hm {
color: #f5a623; color: #ffffff;
} }
.aff-quais-departs-redesign__time--long-text { .aff-classique-quai-dep__clock-s {
font-size: 3.5rem !important; color: #ff8c00;
line-height: 1.2 !important; margin-left: 2px;
} }
.aff-quais-departs-redesign__time--suppressed,
.aff-quais-departs-redesign__status--suppressed {
color: #ff4c5b !important;
}
.aff-quais-departs-redesign__time--delayed,
.aff-quais-departs-redesign__status--delayed {
color: #ff9e43 !important;
}
/* Disrupted Stop styling */
.aff-quais-departs-redesign__stop-text--suppressed {
text-decoration: line-through;
color: #ff4c5b;
opacity: 0.6;
}
.aff-quais-departs-redesign__stop-text--delayed {
color: #ff9e43;
}
.aff-quais-departs-redesign__stop-status-badge {
font-size: 2.2rem;
font-weight: bold;
margin-left: 10px;
}
.aff-quais-departs-redesign__stop-status-badge--suppressed {
color: #ff4c5b;
}
.aff-quais-departs-redesign__stop-status-badge--delayed {
color: #ff9e43;
}
File diff suppressed because it is too large. Load diff
+38 -7
View File
@@ -185,13 +185,25 @@
color: #1a2b4c !important; color: #1a2b4c !important;
} }
.eva-arr-status-pill--approaching {
background-color: #d7e1f0 !important;
border: 1.8px solid #7d95b5 !important;
color: #1a2b4c !important;
}
.eva-arr-status-pill--atplatform {
background-color: #1a2b4c !important;
border: none !important;
color: #ffffff !important;
}
.eva-arr-status-pill--delayed { .eva-arr-status-pill--delayed {
background-color: #ffa74f !important; background-color: #ffa74f !important;
color: #000c2f !important; color: #000c2f !important;
} }
.eva-arr-status-pill--suppressed { .eva-arr-status-pill--suppressed {
background-color: #ff5c73 !important; background-color: #ff4d6d !important;
color: #ffffff !important; color: #ffffff !important;
} }
@@ -242,20 +254,28 @@
text-overflow: ellipsis !important; text-overflow: ellipsis !important;
} }
/* Badge Motif de Perturbation (ex: Embouteillages) */ /* Badge Motif de Perturbation */
.eva-arr-perturb-badge { .eva-arr-perturb-badge {
display: inline-flex !important; display: inline-flex !important;
align-items: center !important; align-items: center !important;
gap: 8px !important; gap: 8px !important;
background-color: #ffa74f !important;
color: #000c2f !important;
padding: 4px 15px !important; padding: 4px 15px !important;
border-radius: 6px !important; border-radius: 6px !important;
height: 37px !important; height: 38px !important;
box-sizing: border-box !important; box-sizing: border-box !important;
flex-shrink: 0 !important; flex-shrink: 0 !important;
} }
.eva-arr-perturb-badge--delayed {
background-color: #ffa74f !important;
color: #000c2f !important;
}
.eva-arr-perturb-badge--suppressed {
background-color: #d63031 !important;
color: #ffffff !important;
}
.eva-arr-perturb-badge-icon { .eva-arr-perturb-badge-icon {
width: 26px !important; width: 26px !important;
height: 26px !important; height: 26px !important;
@@ -263,12 +283,23 @@
} }
.eva-arr-perturb-badge-text { .eva-arr-perturb-badge-text {
font-size: 24px !important; font-size: 22px !important;
font-weight: 700 !important; font-weight: 700 !important;
line-height: 1 !important; line-height: 1.1 !important;
white-space: nowrap !important; white-space: nowrap !important;
} }
.eva-arr-vias-direct {
font-size: 38px !important;
font-weight: 700 !important;
color: #00c284 !important;
line-height: 1 !important;
}
.eva-arr-vias-stop--cancelled {
color: #8b9bb4 !important;
}
/* Section Vias */ /* Section Vias */
.eva-arr-vias-container { .eva-arr-vias-container {
display: flex !important; display: flex !important;
+124 -30
View File
@@ -1,3 +1,4 @@
/* eslint-disable */
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import './EVAArrivees.css'; import './EVAArrivees.css';
import supabase from '../../../lib/supabase'; import supabase from '../../../lib/supabase';
@@ -178,6 +179,41 @@ const EVACrossIcon = ({ size = '27px', color = 'currentColor', style = {} }) =>
</svg> </svg>
); );
// Icône information carrée orange (Badge motif retard EVA Image 1)
const EVAPerturbInfoSquare = ({ size = '36px' }) => (
<svg viewBox="0 0 36 36" width={size} height={size} style={{ flexShrink: 0 }}>
<rect width="36" height="36" rx="6" fill="#fca758" />
<circle cx="18" cy="18" r="10" stroke="#000c2f" strokeWidth="2.2" fill="none" />
<circle cx="18" cy="13" r="1.5" fill="#000c2f" />
<path d="M16.5 17h2v6h2" stroke="#000c2f" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
// Icône triangle rouge alerte (Badge motif suppression EVA Image 1)
const EVAPerturbWarningSquare = ({ size = '36px' }) => (
<svg viewBox="0 0 36 36" width={size} height={size} style={{ flexShrink: 0 }}>
<rect width="36" height="36" rx="6" fill="#e63946" />
<path d="M18 7l11 19H7L18 7z" stroke="#ffffff" strokeWidth="2.2" fill="none" strokeLinejoin="round" />
<line x1="18" y1="14" x2="18" y2="19" stroke="#ffffff" strokeWidth="2.4" strokeLinecap="round" />
<circle cx="18" cy="22.5" r="1.3" fill="#ffffff" />
</svg>
);
// Icône Train barré en rouge (Boîte Quai pour train supprimé EVA Image 1, 2, 3)
const EVACancelledTrainIcon = ({ size = '60px' }) => (
<svg viewBox="0 0 64 64" width={size} height={size} style={{ flexShrink: 0 }}>
<rect x="18" y="10" width="28" height="36" rx="7" fill="#ffffff" />
<rect x="22" y="15" width="20" height="11" rx="3" fill="#000c2f" />
<circle cx="24" cy="35" r="2.8" fill="#000c2f" />
<circle cx="40" cy="35" r="2.8" fill="#000c2f" />
<rect x="28" y="38" width="8" height="2" rx="1" fill="#000c2f" />
<circle cx="21" cy="46" r="2.2" fill="#ffffff" />
<circle cx="43" cy="46" r="2.2" fill="#ffffff" />
<line x1="16" y1="48" x2="48" y2="48" stroke="#ffffff" strokeWidth="2.5" strokeLinecap="round" />
<line x1="8" y1="56" x2="56" y2="8" stroke="#ff3b53" strokeWidth="5.5" strokeLinecap="round" />
</svg>
);
// Défilement des gares de passage (Vias) // Défilement des gares de passage (Vias)
const EVAViasMarquee = ({ viaNames }) => { const EVAViasMarquee = ({ viaNames }) => {
const containerRef = useRef(null); const containerRef = useRef(null);
@@ -206,7 +242,7 @@ const EVAViasMarquee = ({ viaNames }) => {
if (!viaNames || viaNames.length === 0) { if (!viaNames || viaNames.length === 0) {
return ( return (
<div className="eva-arr-vias-container"> <div className="eva-arr-vias-container">
<span className="eva-arr-vias-stop">Direct</span> <span className="eva-arr-vias-direct">train direct</span>
</div> </div>
); );
} }
@@ -222,12 +258,24 @@ const EVAViasMarquee = ({ viaNames }) => {
animationDuration: isOverflowing ? `${duration}s` : undefined animationDuration: isOverflowing ? `${duration}s` : undefined
}} }}
> >
{viaNames.map((v, i) => ( {viaNames.map((v, i) => {
<React.Fragment key={i}> const isObj = typeof v === 'object' && v !== null;
{i > 0 && <span className="eva-arr-vias-bullet">•</span>} const name = isObj ? v.nom || v.name : v;
<span className="eva-arr-vias-stop">{v}</span> const isCancelled = isObj && Boolean(v.isCancelled || v.cancelled || v.supprime);
</React.Fragment> return (
))} <React.Fragment key={i}>
{i > 0 && <span className="eva-arr-vias-bullet">•</span>}
{isCancelled ? (
<span className="eva-arr-vias-stop eva-arr-vias-stop--cancelled">
<span style={{ textDecoration: 'line-through' }}>{name}</span>
<span style={{ color: '#e63946', fontWeight: 800, marginLeft: '6px' }}>✕</span>
</span>
) : (
<span className="eva-arr-vias-stop">{name}</span>
)}
</React.Fragment>
);
})}
</div> </div>
</div> </div>
</div> </div>
@@ -809,7 +857,12 @@ export default function EVAArrivees() {
} }
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; if (maxDelay != null) {
const dH = Math.floor(maxDelay / 60);
const dRem = maxDelay % 60;
const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`;
return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails };
}
return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
@@ -1230,7 +1283,7 @@ export default function EVAArrivees() {
fetchData(); fetchData();
const intervalId = setInterval(fetchData, 15000); const intervalId = setInterval(fetchData, 15000);
return () => { mounted = false; clearInterval(intervalId); }; return () => { mounted = false; clearInterval(intervalId); };
}, [gareDisplay, gareParam, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]); }, [gareDisplay, gareParam, gareIdParam]);
// Alternance retard / texte toutes les 2s // Alternance retard / texte toutes les 2s
const [alternateDelay, setAlternateDelay] = useState(false); const [alternateDelay, setAlternateDelay] = useState(false);
@@ -1309,6 +1362,15 @@ export default function EVAArrivees() {
return null; return null;
})(); })();
const formatTrainNumber = (num) => {
if (!num) return '';
const s = String(num).trim();
if (/^\d{4,6}$/.test(s)) {
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
return s;
};
return ( return (
<div className="eva-arr-container"> <div className="eva-arr-container">
{/* HEADER */} {/* HEADER */}
@@ -1316,9 +1378,9 @@ export default function EVAArrivees() {
<div className="eva-arr-header-left"> <div className="eva-arr-header-left">
<EVAArrivalsHeaderIcon className="eva-arr-header-icon" /> <EVAArrivalsHeaderIcon className="eva-arr-header-icon" />
<div className="eva-arr-header-titles"> <div className="eva-arr-header-titles">
<span className="eva-arr-header-title-main">Arrivées</span> <span className="eva-arr-header-title-main">ARRIVÉES</span>
<span className="eva-arr-header-slash">/</span> <span className="eva-arr-header-slash">/</span>
<span className="eva-arr-header-title-sub">Arrivals From / Ankünfte Aus</span> <span className="eva-arr-header-title-sub">ARRIVALS FROM / LLEGADAS DE</span>
</div> </div>
</div> </div>
@@ -1350,7 +1412,7 @@ export default function EVAArrivees() {
horaire.voie || horaire.voie ||
horaire.track || horaire.track ||
'' ''
).toString().trim(); ).toString().replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
const isSub = (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero)); const isSub = (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero));
const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR'); const isCarTER = (op === 'CAR TER' || op === 'AUTOCAR');
@@ -1367,19 +1429,42 @@ export default function EVAArrivees() {
let delayVal = '+10 min'; let delayVal = '+10 min';
if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) {
delayVal = `+${status.delayMinutes} min`; delayVal = `+ ${status.delayMinutes} min`;
} else if (status?.delayMinutes) { } else if (status?.delayMinutes) {
delayVal = `+${status.delayMinutes} min`; delayVal = `+ ${status.delayMinutes} min`;
} else if (status?.label && status.label.includes('+')) { } else if (status?.label && status.label.includes('+')) {
const m = status.label.match(/\+(\d+)\s*(?:min)?/); const m = status.label.match(/\+(\d+)\s*(?:min)?/);
if (m) delayVal = `+${m[1]} min`; if (m) delayVal = `+ ${m[1]} min`;
else delayVal = status.label; else delayVal = status.label;
} }
let pillText = isSuppressed ? 'supprimé' : isDelayed ? (alternateDelay ? 'retardé' : delayVal) : 'à l\'heure'; const timeHM = horaire._arrHM || 0;
const pillModifier = isSuppressed ? 'suppressed' : isDelayed ? 'delayed' : 'ontime'; const isAtPlatform = !isSuppressed && !isDelayed && timeHM === currentHM;
const isApproaching = !isSuppressed && !isDelayed && (timeHM === currentHM + 1 || timeHM === currentHM + 2);
// Motif de perturbation (ex: Embouteillages, Travaux, etc.) let pillText = "à l'heure";
let pillModifier = 'ontime';
let showClockIcon = true;
if (isSuppressed) {
pillText = alternateDelay ? 'Train' : 'supprimé';
pillModifier = 'suppressed';
showClockIcon = true;
} else if (isDelayed) {
pillText = alternateDelay ? 'retardé' : delayVal;
pillModifier = 'delayed';
showClockIcon = true;
} else if (isAtPlatform) {
pillText = 'à quai';
pillModifier = 'atplatform';
showClockIcon = false;
} else if (isApproaching) {
pillText = "à l'approche";
pillModifier = 'approaching';
showClockIcon = false;
}
// Motif de perturbation (ex: Défaut alimentation électrique, Train en panne, etc.)
const perturbationReason = decodeText(status?.causeDetails || (isDelayed && !status?.causeDetails ? 'Retard' : '')); const perturbationReason = decodeText(status?.causeDetails || (isDelayed && !status?.causeDetails ? 'Retard' : ''));
// Calcul des gares intermédiaires (Vias) // Calcul des gares intermédiaires (Vias)
@@ -1392,7 +1477,8 @@ export default function EVAArrivees() {
const norm = normalizeName(sName); const norm = normalizeName(sName);
if (norm === normalizeName(origin || '')) continue; if (norm === normalizeName(origin || '')) continue;
if (norm === normalizeName(stationName || '')) break; if (norm === normalizeName(stationName || '')) break;
viaNames.push(decodeText(sName)); const isCancelledStop = s.is_cancelled || s.supprime || s.type === 'arret_supprime';
viaNames.push({ nom: decodeText(sName), isCancelled: isCancelledStop });
} }
} }
@@ -1404,10 +1490,11 @@ export default function EVAArrivees() {
<div className="eva-arr-col-time"> <div className="eva-arr-col-time">
<div className="eva-arr-time-value">{time}</div> <div className="eva-arr-time-value">{time}</div>
<div className={`eva-arr-status-pill eva-arr-status-pill--${pillModifier}`}> <div className={`eva-arr-status-pill eva-arr-status-pill--${pillModifier}`}>
{isSuppressed ? ( {showClockIcon && (
<EVACrossIcon className="eva-arr-status-icon" color="#ffffff" /> <EVAClockIcon
) : ( className="eva-arr-status-icon"
<EVAClockIcon className="eva-arr-status-icon" color={isDelayed ? '#000c2f' : '#1a2b4c'} /> color={isSuppressed ? '#ffffff' : isDelayed ? '#000c2f' : '#1a2b4c'}
/>
)} )}
<span className="eva-arr-status-text">{pillText}</span> <span className="eva-arr-status-text">{pillText}</span>
</div> </div>
@@ -1418,10 +1505,13 @@ export default function EVAArrivees() {
<div className="eva-arr-origin-row"> <div className="eva-arr-origin-row">
<MarqueeText text={decodedOrigin} className="eva-arr-origin-name" style={{ flex: 'none' }} /> <MarqueeText text={decodedOrigin} className="eva-arr-origin-name" style={{ flex: 'none' }} />
{perturbationReason && ( {perturbationReason && (
<div className="eva-arr-perturb-badge"> alternateDelay ? (
<EVAInfoIcon className="eva-arr-perturb-badge-icon" /> <div className={`eva-arr-perturb-badge ${isSuppressed ? 'eva-arr-perturb-badge--suppressed' : 'eva-arr-perturb-badge--delayed'}`}>
<span className="eva-arr-perturb-badge-text">{perturbationReason}</span> <span className="eva-arr-perturb-badge-text">{perturbationReason}</span>
</div> </div>
) : (
isSuppressed ? <EVAPerturbWarningSquare /> : <EVAPerturbInfoSquare />
)
)} )}
</div> </div>
<EVAViasMarquee viaNames={viaNames} /> <EVAViasMarquee viaNames={viaNames} />
@@ -1429,11 +1519,15 @@ 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">
{isSub || isCarTER ? ( {isSuppressed ? (
<div className="eva-arr-voie-box">
<EVACancelledTrainIcon />
</div>
) : (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 && !isSuppressed) ? ( ) : rawPlatform ? (
<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>
@@ -1442,7 +1536,7 @@ export default function EVAArrivees() {
<div className="eva-arr-train-block"> <div className="eva-arr-train-block">
<span className="eva-arr-train-op">{op}</span> <span className="eva-arr-train-op">{op}</span>
<span className="eva-arr-train-num">{number}</span> <span className="eva-arr-train-num">{formatTrainNumber(number)}</span>
</div> </div>
</div> </div>
</li> </li>
+8 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable */
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import './EVADeparts.css'; import './EVADeparts.css';
import supabase from '../../../lib/supabase'; import supabase from '../../../lib/supabase';
@@ -805,7 +806,12 @@ export default function EVADeparts() {
} }
if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails };
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; if (maxDelay != null) {
const dH = Math.floor(maxDelay / 60);
const dRem = maxDelay % 60;
const dStr = maxDelay >= 60 ? (dRem > 0 ? `${dH}h${String(dRem).padStart(2, '0')}` : `${dH}h`) : `${maxDelay}min.`;
return { label: `retardé +${dStr}`, detail: '', delayMinutes: maxDelay, causeDetails };
}
return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails };
}, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]);
@@ -1243,7 +1249,7 @@ export default function EVADeparts() {
fetchData(); fetchData();
const intervalId = setInterval(fetchData, 15000); const intervalId = setInterval(fetchData, 15000);
return () => { mounted = false; clearInterval(intervalId); }; return () => { mounted = false; clearInterval(intervalId); };
}, [gareDisplay, gareParam, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]); }, [gareDisplay, gareParam, gareIdParam]);
// Alternance retard / texte toutes les 2s // Alternance retard / texte toutes les 2s
const [alternateDelay, setAlternateDelay] = useState(false); const [alternateDelay, setAlternateDelay] = useState(false);
+3 -3
View File
@@ -160,7 +160,7 @@ async function fetchTransitFromNavitia(lat, lon, stationName) {
const mode = (info.commercial_mode || info.physical_mode || 'bus').toLowerCase(); 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 type = mode.includes('tram') ? 'tram' : (mode.includes('coach') || mode.includes('car') ? 'car' : 'bus');
const cleanDir = direction.replace(/^[\s\(\[\"']+|[\s\)\]\"']+$/g, ''); const cleanDir = direction.replace(/^[\s(["']+|[\s)\]"']+$/g, '');
const formattedDest = cleanDir.toLowerCase().startsWith('gare') const formattedDest = cleanDir.toLowerCase().startsWith('gare')
? cleanDir ? cleanDir
: `${stopName} - (${cleanDir})`; : `${stopName} - (${cleanDir})`;
@@ -328,7 +328,7 @@ async function fetchTransitFromOpenApi(lat, lon, stationName) {
const rawDest = toDest || fromDest; const rawDest = toDest || fromDest;
if (!rawDest) continue; if (!rawDest) continue;
const cleanDest = rawDest.replace(/^[\s\(\[\"']+|[\s\)\]\"']+$/g, ''); const cleanDest = rawDest.replace(/^[\s(["']+|[\s)\]"']+$/g, '');
const formattedDest = cleanDest.toLowerCase().startsWith('gare') const formattedDest = cleanDest.toLowerCase().startsWith('gare')
? cleanDest ? cleanDest
: `Gare - (${cleanDest})`; : `Gare - (${cleanDest})`;
@@ -678,7 +678,7 @@ export function formatWaitTimeDisplay(waitMinutes) {
* Générateur de lignes contextuelles de secours pour une commune ou gare intermédiaire * Générateur de lignes contextuelles de secours pour une commune ou gare intermédiaire
*/ */
function generateContextualLinesForStation(stationName) { function generateContextualLinesForStation(stationName) {
const cleanName = (stationName || 'Gare').replace(/^(Gare\s+de\s+|Gare\s+d\'|Gare\s+)/i, '').trim(); const cleanName = (stationName || 'Gare').replace(/^(Gare\s+de\s+|Gare\s+d'|Gare\s+)/i, '').trim();
return [ return [
{ {
+90 -8
View File
@@ -24569,6 +24569,7 @@ function cleanStationQuery(s) {
const STATION_ALIASES = { const STATION_ALIASES = {
'valence tgv': '87763029', 'valence tgv': '87763029',
'valence': '87761007', 'valence': '87761007',
'valence ville': '87761007',
'paris nord': '87271007', 'paris nord': '87271007',
'paris gare du nord': '87271007', 'paris gare du nord': '87271007',
'paris est': '87113001', 'paris est': '87113001',
@@ -24581,11 +24582,18 @@ const STATION_ALIASES = {
'austerlitz': '87547000', 'austerlitz': '87547000',
'paris bercy': '87686667', 'paris bercy': '87686667',
'bercy': '87686667', 'bercy': '87686667',
'paris saint lazare': '87384008',
'saint lazare': '87384008',
'marseille': '87751008', 'marseille': '87751008',
'marseille saint charles': '87751008', 'marseille saint charles': '87751008',
'marseille st charles': '87751008', 'marseille st charles': '87751008',
'marseille blancarde': '87751081',
'lyon part dieu': '87723197', 'lyon part dieu': '87723197',
'lyon perrache': '87722025', 'lyon perrache': '87722025',
'lyon jean mace': '87282624',
'lyon gorge de loup': '87721175',
'lyon vaise': '87721001',
'lyon saint paul': '87721159',
'lyon saint exupery': '87762906', 'lyon saint exupery': '87762906',
'lyon st exupery': '87762906', 'lyon st exupery': '87762906',
'toulouse': '87611004', 'toulouse': '87611004',
@@ -24614,9 +24622,73 @@ const STATION_ALIASES = {
'besancon franche comte tgv': '87300863', 'besancon franche comte tgv': '87300863',
'dijon': '87713040', 'dijon': '87713040',
'dijon ville': '87713040', 'dijon ville': '87713040',
'dijon centre': '87713040',
'dijon porte neuve': '87713073',
'dijon porte-neuve': '87713073',
'porte neuve': '87713073',
'cannes': '87757625',
'cannes la bocca': '87757617',
'nice': '87756056',
'nice ville': '87756056',
'nice riquier': '87756353',
'nice saint augustin': '87756254',
'toulon': '87755009',
'toulon sainte musse': '87742262',
'amiens': '87313874' 'amiens': '87313874'
}; };
/**
* Normalise un nom de gare pour comparaison stricte
*/
export function normalizeStation(name) {
if (!name) return '';
return String(name)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/^gare\s+d['e]\s+/i, '')
.replace(/[-_/.,()'&]+/g, ' ')
.replace(/\bst\b/g, 'saint')
.replace(/\bste\b/g, 'sainte')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Compare strictement deux noms de gares pour éviter la confusion entre
* gares de même ville (ex: "Dijon" vs "Dijon Porte Neuve", "Lyon Part Dieu" vs "Lyon Perrache")
*/
export function matchStationNameStrict(a, b) {
if (!a || !b) return false;
const normA = normalizeStation(a);
const normB = normalizeStation(b);
if (!normA || !normB) return false;
if (normA === normB) return true;
// Liste des qualificatifs de sous-gares / gares secondaires ou périphériques distinctes
const distinctQualifiers = [
'porte neuve', 'part dieu', 'perrache', 'jean mace', 'gorge de loup', 'vaise',
'saint paul', 'saint augustin', 'riquier', 'la bocca', 'bocca', 'pont du gard',
'sud de france', 'sainte musse', 'europe', 'flandres', 'chateaucreux', 'bellevue',
'carnot', 'terrasse', 'estressin', 'roche', 'franche comte tgv', 'montbeliard tgv'
];
// Si l'une contient un qualificatif distinct et pas l'autre -> Ne correspondent PAS
for (const q of distinctQualifiers) {
const aHas = normA.includes(q);
const bHas = normB.includes(q);
if (aHas !== bHas) {
return false;
}
}
// Si on retire "ville" ou "centre", vérifions si elles sont identiques
const stripGeneric = (s) => s.replace(/\b(ville|centre|gare)\b/g, '').replace(/\s+/g, ' ').trim();
const sA = stripGeneric(normA);
const sB = stripGeneric(normB);
return sA === sB;
}
export function findGtfsGare(query) { export function findGtfsGare(query) {
if (!query) return null; if (!query) return null;
const rawQ = String(query).trim(); const rawQ = String(query).trim();
@@ -24633,7 +24705,11 @@ export function findGtfsGare(query) {
if (found) return found; if (found) return found;
} }
// 3. Algorithme de scoring par tokens et similarité // 3. Recherche par correspondance exacte nette
const exact = GTFS_GARES.find(g => cleanStationQuery(g.nom) === qClean);
if (exact) return exact;
// 4. Algorithme de scoring par tokens et similarité
const qTokens = qClean.split(' ').filter(Boolean); const qTokens = qClean.split(' ').filter(Boolean);
const wantsTgv = qTokens.includes('tgv'); const wantsTgv = qTokens.includes('tgv');
@@ -24649,8 +24725,12 @@ export function findGtfsGare(query) {
if (gClean === qClean) { if (gClean === qClean) {
score = 1000; score = 1000;
} else if (gClean.startsWith(qClean)) { } else if (matchStationNameStrict(g.nom, rawQ)) {
score = 500; score = 900;
} else if (gClean.startsWith(qClean + ' ')) {
// Sous-gare ayant le préfixe mais plus longue (ex: "dijon porte neuve" quand la requête est "dijon")
// Pénaliser si l'utilisateur a tapé une requête courte exacte
score = 400 - (gTokens.length - qTokens.length) * 50;
} else { } else {
let allFound = true; let allFound = true;
let tokenMatches = 0; let tokenMatches = 0;
@@ -24665,15 +24745,15 @@ export function findGtfsGare(query) {
} }
if (allFound) { if (allFound) {
score = 200 + tokenMatches * 20 - (gTokens.length - qTokens.length) * 5; score = 300 + tokenMatches * 20 - (gTokens.length - qTokens.length) * 40;
} else if (tokenMatches > 0) { } else if (tokenMatches > 0) {
score = tokenMatches * 15 - (gTokens.length - tokenMatches) * 5; score = tokenMatches * 15 - (gTokens.length - tokenMatches) * 30;
} }
} }
if (wantsTgv && hasTgv) score += 150; if (wantsTgv && hasTgv) score += 150;
if (wantsTgv && !hasTgv) score -= 200; if (wantsTgv && !hasTgv) score -= 200;
if (!wantsTgv && hasTgv) score -= 50; if (!wantsTgv && hasTgv) score -= 100;
if (!wantsTgv && gClean.includes('ville') && qTokens.length === 1 && gTokens[0] === qTokens[0]) { if (!wantsTgv && gClean.includes('ville') && qTokens.length === 1 && gTokens[0] === qTokens[0]) {
score += 100; score += 100;
@@ -24707,6 +24787,8 @@ export function searchGtfsGares(query, limit = 20) {
if (gClean === qClean) { if (gClean === qClean) {
scored.push({ gare: g, score: 1000 }); scored.push({ gare: g, score: 1000 });
} else if (matchStationNameStrict(g.nom, query)) {
scored.push({ gare: g, score: 950 });
} else if (gClean.startsWith(qClean)) { } else if (gClean.startsWith(qClean)) {
scored.push({ gare: g, score: 800 }); scored.push({ gare: g, score: 800 });
} else { } else {
@@ -24718,9 +24800,9 @@ export function searchGtfsGares(query, limit = 20) {
} }
} }
if (matchCount === qTokens.length) { if (matchCount === qTokens.length) {
scored.push({ gare: g, score: 500 + matchCount * 10 - gTokens.length }); scored.push({ gare: g, score: 500 + matchCount * 10 - gTokens.length * 10 });
} else if (matchCount > 0) { } else if (matchCount > 0) {
scored.push({ gare: g, score: matchCount * 20 - gTokens.length }); scored.push({ gare: g, score: matchCount * 20 - gTokens.length * 10 });
} }
} }
} }
+665 -158
View File
@@ -1,3 +1,81 @@
/* eslint-disable */
let isRealtimeRefreshing = false;
// Fonctions de persistance du cache temps réel dans localStorage et sessionStorage
function saveRealtimeCacheToStorage() {
if (typeof window === 'undefined') return;
try {
if (siriLiteCache.platforms && siriLiteCache.platforms.size > 0) {
const arr = Array.from(siriLiteCache.platforms.entries()).slice(0, 4000);
const payload = JSON.stringify({
ts: siriLiteCache.timestamp,
data: arr
});
if (window.localStorage) {
window.localStorage.setItem('sncf_siri_platforms', payload);
}
if (window.sessionStorage) {
window.sessionStorage.setItem('sncf_siri_platforms', payload);
}
}
} catch (_) {}
}
function restoreRealtimeCacheFromStorage() {
if (typeof window === 'undefined') return;
try {
const raw = (window.localStorage && window.localStorage.getItem('sncf_siri_platforms')) ||
(window.sessionStorage && window.sessionStorage.getItem('sncf_siri_platforms'));
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.data) && (Date.now() - parsed.ts < 1800000)) {
siriLiteCache.timestamp = parsed.ts;
siriLiteCache.platforms = new Map(parsed.data);
}
}
} catch (_) {}
}
let lastRealtimeTriggerTime = 0;
let activeRealtimePromise = null;
export async function triggerRealtimeBackgroundRefresh(force = false) {
const now = Date.now();
if (activeRealtimePromise) return activeRealtimePromise;
if (!force && (now - lastRealtimeTriggerTime < 30000)) return;
lastRealtimeTriggerTime = now;
const needRt = force || (now - gtfsRtCache.timestamp > 30000) || gtfsRtCache.updates.size === 0;
const needSiri = force || (now - siriLiteCache.timestamp > 60000) || siriLiteCache.platforms.size === 0;
const needSx = force || (now - situationExchangeCache.timestamp > 60000) || situationExchangeCache.platforms.size === 0;
if (!needRt && !needSiri && !needSx) return;
activeRealtimePromise = (async () => {
isRealtimeRefreshing = true;
try {
const promises = [];
if (needRt) promises.push(fetchGtfsRealtimeData());
if (needSiri) promises.push(fetchSiriLiteData());
if (needSx) promises.push(fetchSituationExchangeData());
await Promise.allSettled(promises);
} catch (_) {
// ignore
} finally {
isRealtimeRefreshing = false;
activeRealtimePromise = null;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('sncf-realtime-updated'));
}
}
})();
return activeRealtimePromise;
}
/* eslint-disable */
// Service d'accès aux données SNCF (GTFS / GTFS-RT / SIRI Lite / Open Data SNCF) // Service d'accès aux données SNCF (GTFS / GTFS-RT / SIRI Lite / Open Data SNCF)
import { transit_realtime } from 'gtfs-realtime-bindings'; import { transit_realtime } from 'gtfs-realtime-bindings';
import { findGtfsGare, searchGtfsGares } from './gtfsStations'; import { findGtfsGare, searchGtfsGares } from './gtfsStations';
@@ -5,23 +83,87 @@ import { findGtfsGare, searchGtfsGares } from './gtfsStations';
const SNCF_GTFS_RT_TRIP_UPDATES = 'https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-trip-updates'; const SNCF_GTFS_RT_TRIP_UPDATES = 'https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-trip-updates';
const SNCF_GTFS_RT_SERVICE_ALERTS = 'https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-service-alerts'; const SNCF_GTFS_RT_SERVICE_ALERTS = 'https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-service-alerts';
const SNCF_SIRI_LITE_TIMETABLE = 'https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-estimated-timetable'; const SNCF_SIRI_LITE_TIMETABLE = 'https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-estimated-timetable';
const SNCF_SIRI_LITE_SITUATION_EXCHANGE = 'https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-situation-exchange';
const NAVITIA_TOKEN = (typeof process !== 'undefined' && process.env && process.env.REACT_APP_NAVITIA_TOKEN) || 'bf06df97-2ff1-4560-b74c-f127cb70764d';
// Cache mémoire pour le flux GTFS-RT (TTL = 30s) // Détection du mode développement pour accélération maximale
const isDev = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'development') ||
(typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'));
// TTL de rafraîchissement des flux temps réel SNCF : 5 minutes en dev, 2 minutes en prod
const REALTIME_CACHE_TTL = isDev ? 300000 : 120000;
// Cache mémoire des données statiques des gares (chargement instantané en dev)
const stationJsonCache = new Map();
// Cache mémoire pour le flux GTFS-RT (TTL = 2 min)
let gtfsRtCache = { let gtfsRtCache = {
timestamp: 0, timestamp: 0,
updates: new Map(), updates: new Map(),
alerts: [] alerts: []
}; };
// Cache mémoire pour le flux SIRI-Lite (TTL = 30s) // Cache mémoire pour le flux SIRI-Lite Estimated Timetable (TTL = 2 min)
let siriLiteCache = { let siriLiteCache = {
timestamp: 0, timestamp: 0,
platforms: new Map(), platforms: new Map(),
updates: new Map() updates: new Map()
}; };
// Cache mémoire pour le flux SIRI-Lite Situation Exchange (TTL = 2 min)
let situationExchangeCache = {
timestamp: 0,
platforms: new Map(),
updates: new Map(),
alerts: []
};
// Cache mémoire pour l'API OpenData SNCF (TTL = 2 min)
restoreRealtimeCacheFromStorage();
const MAJOR_HUBS = [
'paris', 'lyon', 'dijon', 'marseille', 'lille', 'strasbourg', 'bordeaux',
'toulouse', 'nantes', 'rennes', 'nice', 'montpellier', 'nimes', 'metz',
'nancy', 'mulhouse', 'tours', 'le mans', 'rouen', 'clermont ferrand',
'grenoble', 'chambery', 'avignon', 'valence', 'besancon viotte',
'saint etienne', 'toulon', 'cannes', 'antibes', 'angers'
];
const MAJOR_HUB_UICS = [
'87713040', '87686006', '87271007', '87391003', '87113001', '87384008',
'87547000', '87686667', '87722025', '87723197', '87751008', '87286005',
'87286013', '87212027', '87581009', '87611004', '87481002', '87471003',
'87756056', '87773002', '87775007', '87192039', '87141002', '87182063',
'87571000', '87444000', '87411017', '87734004', '87747006', '87741009',
'87765008', '87318964', '87763029', '87718007', '87726000', '87755009',
'87757625', '87757674', '87484006'
];
export function isMajorHub(stationName, uic = '') {
if (uic && MAJOR_HUB_UICS.includes(String(uic).replace(/\D/g, ''))) return true;
if (!stationName) return false;
const clean = String(stationName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim();
return MAJOR_HUBS.some(hub => clean === hub || clean.startsWith(hub + ' ') || clean.endsWith(' ' + hub));
}
export function isCarNumber(num) {
const d = String(num || '').replace(/\D/g, '');
return (d.length === 5 && d.startsWith('3')) || (d.length === 6 && d.startsWith('3'));
}
/**
* Résout la voie pour une circulation :
* - Aucune voie nominale/fictive inventée : seuls les quais temps réel SNCF sont affichés.
* - Les services par autocar affichent 'AUTOCAR' (pictogramme).
*/
export function resolveNominalTrack(stationName, trainNum, uic = '') {
const rawNum = String(trainNum || '').replace(/\D/g, '');
if (isCarNumber(rawNum)) return 'AUTOCAR';
return '';
}
// Helper fetch avec timeout pour ne jamais bloquer l'affichage en cas de réseau lent ou de blocage // Helper fetch avec timeout pour ne jamais bloquer l'affichage en cas de réseau lent ou de blocage
async function fetchWithTimeout(url, options = {}, timeoutMs = 7000) { async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
const controller = new AbortController(); const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs); const id = setTimeout(() => controller.abort(), timeoutMs);
try { try {
@@ -40,109 +182,390 @@ function cleanNum(n) {
return m ? m[0].replace(/^0+/, '') : String(n).trim(); return m ? m[0].replace(/^0+/, '') : String(n).trim();
} }
/** function extractAllTrainNumbers(journeyXml) {
* Récupère et décode les flux temps réel SIRI Lite SNCF (Quais réels et horaires estimés) const nums = new Set();
*/
async function fetchSiriLiteData() { // 1. TrainNumberRef
const now = Date.now(); const tnr = journeyXml.match(/<TrainNumberRef>([^<]+)<\/TrainNumberRef>/);
if (now - siriLiteCache.timestamp < 30000 && siriLiteCache.platforms.size > 0) { if (tnr) {
return siriLiteCache; const c = cleanNum(tnr[1]);
if (c) nums.add(c);
} }
const platformsMap = new Map(); // 2. VehicleJourneyName
const updatesMap = new Map(); const vjn = journeyXml.match(/<VehicleJourneyName>([^<]+)<\/VehicleJourneyName>/);
if (vjn) {
const c = cleanNum(vjn[1]);
if (c) nums.add(c);
}
try { // 3. PublishedLineName if numeric
const res = await fetchWithTimeout(SNCF_SIRI_LITE_TIMETABLE, {}, 8000); const pln = journeyXml.match(/<PublishedLineName>([^<]+)<\/PublishedLineName>/);
if (res && res.ok) { if (pln) {
const xml = await res.text(); const m = pln[1].match(/^\d+$/);
const journeys = xml.split('<EstimatedVehicleJourney>'); if (m) nums.add(cleanNum(m[0]));
}
for (let i = 1; i < journeys.length; i++) { // 4. DatedVehicleJourneyRef / VehicleJourneyRef / FramedVehicleJourneyRef
const j = journeys[i]; const refs = journeyXml.match(/<(?:DatedVehicleJourneyRef|VehicleJourneyRef|FramedVehicleJourneyRef)>([\s\S]*?)<\/(?:DatedVehicleJourneyRef|VehicleJourneyRef|FramedVehicleJourneyRef)>/g) || [];
const trainNumMatch = j.match(/<TrainNumberRef>([^<]+)<\/TrainNumberRef>/); for (const r of refs) {
const rawTrainNum = trainNumMatch ? trainNumMatch[1].trim() : ''; const vjMatch = r.match(/FR:VehicleJourney::(\d{3,7})/i) || r.match(/VehicleJourney::(\d{3,7})/i) || r.match(/::(\d{3,7})/);
const trainNum = cleanNum(rawTrainNum); if (vjMatch) {
nums.add(cleanNum(vjMatch[1]));
}
const lrMatch = r.match(/LineRef::(\d{3,7})/i);
if (lrMatch) {
nums.add(cleanNum(lrMatch[1]));
}
}
const journeyRefMatch = j.match(/<DatedVehicleJourneyRef>([^<]+)<\/DatedVehicleJourneyRef>/) || j.match(/<VehicleJourneyRef>([^<]+)<\/VehicleJourneyRef>/); return Array.from(nums);
const journeyRef = journeyRefMatch ? journeyRefMatch[1].trim() : ''; }
const isCanceled = j.includes('<Cancellation>true</Cancellation>');
// Extraire les arrêts et quais (RecordedCall et EstimatedCall) function processJourneyXml(j, platformsMap, updatesMap) {
const callMatches = j.match(/<(RecordedCall|EstimatedCall)>[\s\S]*?<\/\1>/g) || []; // 1. Extraction exhaustive des identifiants et numéros de train
for (const call of callMatches) { const trainNums = extractAllTrainNumbers(j);
const stopRefMatch = call.match(/<StopPointRef>[^:]*::(\d+)<\/StopPointRef>/) || call.match(/<StopPointRef>([^<]+)<\/StopPointRef>/);
const uicMatch = stopRefMatch ? (stopRefMatch[1].match(/\d{7,8}/) || [stopRefMatch[1]]) : null;
const uic = uicMatch ? uicMatch[0] : '';
const stopNameMatch = call.match(/<StopPointName>([^<]+)<\/StopPointName>/); const journeyRefMatch = j.match(/<DatedVehicleJourneyRef>([^<]+)<\/DatedVehicleJourneyRef>/) ||
const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; j.match(/<VehicleJourneyRef>([^<]+)<\/VehicleJourneyRef>/) ||
j.match(/<FramedVehicleJourneyRef>[\s\S]*?<DatedVehicleJourneyRef>([^<]+)<\/DatedVehicleJourneyRef>/);
const journeyRef = journeyRefMatch ? journeyRefMatch[1].trim() : '';
const depPlatMatch = call.match(/<DeparturePlatformName>([^<]+)<\/DeparturePlatformName>/); const isCanceledJourney = j.includes('<Cancellation>true</Cancellation>') || j.includes('<JourneyStatus>cancelled</JourneyStatus>');
const arrPlatMatch = call.match(/<ArrivalPlatformName>([^<]+)<\/ArrivalPlatformName>/);
const platform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : '')).trim();
const expDepMatch = call.match(/<ExpectedDepartureTime>([^<]+)<\/ExpectedDepartureTime>/); // 2. Extraire tous les arrêts desservis (RecordedCall et EstimatedCall)
const aimDepMatch = call.match(/<AimedDepartureTime>([^<]+)<\/AimedDepartureTime>/); const callMatches = j.match(/<(?:RecordedCall|EstimatedCall)>[\s\S]*?<\/(?:RecordedCall|EstimatedCall)>/g) || [];
for (const call of callMatches) {
// Extraction du code UIC de l'arrêt
const stopRefMatch = call.match(/<StopPointRef>([^<]+)<\/StopPointRef>/);
let uic = '';
let uic7 = '';
if (stopRefMatch) {
const rawRef = stopRefMatch[1];
const uicDigits = rawRef.match(/\d{7,8}/);
uic = uicDigits ? uicDigits[0] : rawRef.replace(/\D/g, '');
if (uic.length === 8 && uic.startsWith('87')) {
uic7 = uic.slice(2);
}
}
let delayMin = 0; const stopNameMatch = call.match(/<StopPointName>([^<]+)<\/StopPointName>/);
if (expDepMatch && aimDepMatch) { const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : '';
const expD = new Date(expDepMatch[1]).getTime();
const aimD = new Date(aimDepMatch[1]).getTime(); // Extraction des quais (Départ, Arrivée, Prévu ou Réel)
if (!isNaN(expD) && !isNaN(aimD)) { const depPlatMatch = call.match(/<(?:DeparturePlatformName|PlannedDeparturePlatformName)>([^<]+)<\/(?:DeparturePlatformName|PlannedDeparturePlatformName)>/);
delayMin = Math.max(0, Math.round((expD - aimD) / 60000)); const arrPlatMatch = call.match(/<(?:ArrivalPlatformName|PlannedArrivalPlatformName)>([^<]+)<\/(?:ArrivalPlatformName|PlannedArrivalPlatformName)>/);
} const quayMatch = call.match(/<QuayRef>([^<]+)<\/QuayRef>/);
let rawPlatform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : (quayMatch ? quayMatch[1] : ''))).trim();
const platform = rawPlatform.replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
// Extraction des heures et calcul du retard
const expDepMatch = call.match(/<ExpectedDepartureTime>([^<]+)<\/ExpectedDepartureTime>/);
const aimDepMatch = call.match(/<AimedDepartureTime>([^<]+)<\/AimedDepartureTime>/);
const expArrMatch = call.match(/<ExpectedArrivalTime>([^<]+)<\/ExpectedArrivalTime>/);
const aimArrMatch = call.match(/<AimedArrivalTime>([^<]+)<\/AimedArrivalTime>/);
let delayMin = 0;
const expTimeStr = (expDepMatch && expDepMatch[1]) || (expArrMatch && expArrMatch[1]);
const aimTimeStr = (aimDepMatch && aimDepMatch[1]) || (aimArrMatch && aimArrMatch[1]);
if (expTimeStr && aimTimeStr) {
const expD = new Date(expTimeStr).getTime();
const aimD = new Date(aimTimeStr).getTime();
if (!isNaN(expD) && !isNaN(aimD)) {
delayMin = Math.max(0, Math.round((expD - aimD) / 60000));
}
}
const isStopCancelled = isCanceledJourney || call.includes('<DepartureStatus>cancelled</DepartureStatus>') || call.includes('<ArrivalStatus>cancelled</ArrivalStatus>') || call.includes('<Cancellation>true</Cancellation>');
if (platform) {
for (const tNum of trainNums) {
platformsMap.set(tNum, platform);
const ct = cleanNum(tNum);
if (ct) platformsMap.set(ct, platform);
if (uic) {
platformsMap.set(`${tNum}_${uic}`, platform);
platformsMap.set(`${uic}_${tNum}`, platform);
if (ct) {
platformsMap.set(`${ct}_${uic}`, platform);
platformsMap.set(`${uic}_${ct}`, platform);
} }
}
if (platform) { if (uic7) {
if (uic) { platformsMap.set(`${tNum}_${uic7}`, platform);
if (trainNum) { platformsMap.set(`${uic7}_${tNum}`, platform);
platformsMap.set(`${trainNum}_${uic}`, platform); if (ct) {
platformsMap.set(`${uic}_${trainNum}`, platform); platformsMap.set(`${ct}_${uic7}`, platform);
} platformsMap.set(`${uic7}_${ct}`, platform);
if (rawTrainNum) {
platformsMap.set(`${rawTrainNum}_${uic}`, platform);
platformsMap.set(`${uic}_${rawTrainNum}`, platform);
}
if (journeyRef) {
platformsMap.set(`${journeyRef}_${uic}`, platform);
platformsMap.set(`${uic}_${journeyRef}`, platform);
}
}
if (stopNameClean) {
if (trainNum) platformsMap.set(`${trainNum}_${stopNameClean}`, platform);
if (rawTrainNum) platformsMap.set(`${rawTrainNum}_${stopNameClean}`, platform);
if (journeyRef) platformsMap.set(`${journeyRef}_${stopNameClean}`, platform);
}
}
if (trainNum || rawTrainNum || journeyRef) {
const info = {
platform: platform,
delayMinutes: delayMin,
isCanceled: isCanceled
};
if (trainNum) updatesMap.set(trainNum, info);
if (rawTrainNum) updatesMap.set(rawTrainNum, info);
if (journeyRef) updatesMap.set(journeyRef, info);
} }
} }
if (stopNameClean) {
platformsMap.set(`${tNum}_${stopNameClean}`, platform);
if (ct) platformsMap.set(`${ct}_${stopNameClean}`, platform);
}
}
if (journeyRef) {
platformsMap.set(journeyRef, platform);
if (uic) {
platformsMap.set(`${journeyRef}_${uic}`, platform);
platformsMap.set(`${uic}_${journeyRef}`, platform);
}
if (stopNameClean) {
platformsMap.set(`${journeyRef}_${stopNameClean}`, platform);
}
} }
} }
siriLiteCache = { const info = {
timestamp: now, platform: platform,
platforms: platformsMap, delayMinutes: delayMin,
updates: updatesMap isCanceled: isStopCancelled
}; };
for (const tNum of trainNums) {
if (uic) updatesMap.set(`${tNum}_${uic}`, info);
updatesMap.set(tNum, info);
}
if (journeyRef) {
if (uic) updatesMap.set(`${journeyRef}_${uic}`, info);
updatesMap.set(journeyRef, info);
}
}
}
/**
* Récupère et décode le flux SIRI Lite SNCF officiel en temps réel (circulations dans les 60 prochaines minutes)
* Source : https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-estimated-timetable
* Décodage en streaming pour apparition instantanée des quais (sans attendre les 22 Mo complets)
*/
async function fetchSiriLiteData() {
const now = Date.now();
if (now - siriLiteCache.timestamp < REALTIME_CACHE_TTL && siriLiteCache.platforms.size > 0) {
return siriLiteCache;
}
// Conserver les quais déjà connus pour enrichir progressivement
const platformsMap = new Map(siriLiteCache.platforms || []);
const updatesMap = new Map(siriLiteCache.updates || []);
try {
const res = await fetchWithTimeout(SNCF_SIRI_LITE_TIMETABLE, {
headers: {
'Accept': 'application/xml, text/xml, */*'
}
}, 45000);
if (res && res.ok) {
let lastDispatchTime = Date.now();
let newPlatformsCount = 0;
if (res.body && typeof res.body.getReader === 'function') {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf('</EstimatedVehicleJourney>')) !== -1) {
const journeyXml = buffer.slice(0, idx + 26);
buffer = buffer.slice(idx + 26);
const beforeSize = platformsMap.size;
processJourneyXml(journeyXml, platformsMap, updatesMap);
if (platformsMap.size > beforeSize) {
newPlatformsCount += (platformsMap.size - beforeSize);
}
}
// Mise à jour continue du cache mémoire et stockage sans provoquer de re-fetch intempestif
const nowTime = Date.now();
if (newPlatformsCount > 0 && (nowTime - lastDispatchTime > 1500)) {
lastDispatchTime = nowTime;
newPlatformsCount = 0;
siriLiteCache = {
timestamp: nowTime,
platforms: new Map(platformsMap),
updates: new Map(updatesMap)
};
saveRealtimeCacheToStorage();
}
}
} finally {
try { reader.releaseLock(); } catch (_) {}
}
} else {
// Fallback sans Web Streams
const xml = await res.text();
const journeys = xml.split('<EstimatedVehicleJourney>');
for (let i = 1; i < journeys.length; i++) {
processJourneyXml(journeys[i], platformsMap, updatesMap);
}
}
if (platformsMap.size > 0 || updatesMap.size > 0) {
siriLiteCache = {
timestamp: Date.now(),
platforms: platformsMap,
updates: updatesMap
};
saveRealtimeCacheToStorage();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('sncf-realtime-updated'));
}
}
}
} catch (err) { } catch (err) {
console.warn('[sncfApi] Notice: SIRI-Lite refresh error/timeout:', err); console.warn('[sncfApi] Notice: SIRI-Lite Estimated Timetable stream refresh:', err);
} }
return siriLiteCache; return siriLiteCache;
} }
/**
* Récupère et décode le flux SIRI Lite Situation Exchange SNCF (Circulations à venir, perturbations, changements de voie, suppressions)
* Source : https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-situation-exchange
*/
async function fetchSituationExchangeData() {
const now = Date.now();
if (now - situationExchangeCache.timestamp < REALTIME_CACHE_TTL && situationExchangeCache.platforms.size > 0) {
return situationExchangeCache;
}
const platformsMap = new Map();
const updatesMap = new Map();
const alertsList = [];
try {
const res = await fetchWithTimeout(SNCF_SIRI_LITE_SITUATION_EXCHANGE, {
headers: {
'Accept': 'application/xml, text/xml, */*'
}
}, 20000);
if (res && res.ok) {
const xml = await res.text();
const situations = xml.split('<PtSituationElement>');
for (let i = 1; i < situations.length; i++) {
const s = situations[i];
// 1. Extraction exhaustive du train et de la mission
const trainNums = extractAllTrainNumbers(s);
const trainNumMatch = s.match(/<TrainNumberRef>([^<]+)<\/TrainNumberRef>/) || s.match(/<VehicleJourneyRef>([^<]+)<\/VehicleJourneyRef>/) || s.match(/<PublishedLineName>([^<]+)<\/PublishedLineName>/);
const rawTrainNum = trainNumMatch ? trainNumMatch[1].trim() : '';
const primaryTrainNum = cleanNum(rawTrainNum);
if (primaryTrainNum && !trainNums.includes(primaryTrainNum)) {
trainNums.push(primaryTrainNum);
}
// 2. Extraction de l'arrêt impacté
const stopRefMatch = s.match(/<StopPointRef>([^<]+)<\/StopPointRef>/);
let uic = '';
let uic7 = '';
if (stopRefMatch) {
const rawRef = stopRefMatch[1];
const uicDigits = rawRef.match(/\d{7,8}/);
uic = uicDigits ? uicDigits[0] : rawRef.replace(/\D/g, '');
if (uic.length === 8 && uic.startsWith('87')) {
uic7 = uic.slice(2);
}
}
const stopNameMatch = s.match(/<StopPointName>([^<]+)<\/StopPointName>/);
const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : '';
// 3. Extraction des messages en français
const summaryFrMatch = s.match(/<Summary[^>]*xml:lang="FR"[^>]*>([^<]+)<\/Summary>/i) || s.match(/<Summary[^>]*>([^<]+)<\/Summary>/i);
const summary = summaryFrMatch ? summaryFrMatch[1].trim() : '';
const descFrMatch = s.match(/<Description[^>]*xml:lang="FR"[^>]*>([^<]+)<\/Description>/i) || s.match(/<Description[^>]*>([^<]+)<\/Description>/i);
const desc = descFrMatch ? descFrMatch[1].trim() : '';
const messageText = desc || summary || '';
// 4. Détection de changement de quai / voie
const platTagMatch = s.match(/<(?:DeparturePlatformName|PlannedDeparturePlatformName|ArrivalPlatformName|PlannedArrivalPlatformName|QuayRef)>([^<]+)<\//i);
let platform = platTagMatch ? platTagMatch[1].replace(/^(?:voie|quai|track|platform)\s*/i, '').trim() : '';
if (!platform && messageText) {
const voieTextMatch = messageText.match(/\b(?:voie|quai)\s+([A-Za-z0-9]+)\b/i);
if (voieTextMatch) {
platform = voieTextMatch[1].trim();
}
}
// 5. Détection de suppression
const isCanceled = /supprim|annul|gelöscht|cancelled|interrompu/i.test(messageText) || s.includes('<Cancellation>true</Cancellation>');
if (platform) {
for (const tNum of trainNums) {
if (uic) {
platformsMap.set(`${tNum}_${uic}`, platform);
platformsMap.set(`${uic}_${tNum}`, platform);
}
if (uic7) {
platformsMap.set(`${tNum}_${uic7}`, platform);
platformsMap.set(`${uic7}_${tNum}`, platform);
}
if (stopNameClean) {
platformsMap.set(`${tNum}_${stopNameClean}`, platform);
}
}
}
const info = {
platform: platform,
delayMinutes: 0,
isCanceled: isCanceled,
cause: summary || desc || ''
};
for (const tNum of trainNums) {
if (uic) updatesMap.set(`${tNum}_${uic}`, info);
updatesMap.set(tNum, info);
}
if (messageText) {
alertsList.push({
header: summary,
text: desc || summary
});
}
}
}
if (platformsMap.size > 0 || updatesMap.size > 0 || alertsList.length > 0) {
situationExchangeCache = {
timestamp: now,
platforms: platformsMap,
updates: updatesMap,
alerts: alertsList
};
}
} catch (err) {
console.warn('[sncfApi] Notice: SIRI-Lite Situation Exchange error/timeout:', err);
}
return situationExchangeCache;
}
/**
* Récupère les quais en temps réel depuis l'API OpenData SNCF (Navitia + OpenDataSoft)
*/
async function fetchOpenDataSncfPlatforms(uic, mode = 'departs') {
// L'API api.sncf.com v1 et data.sncf.com (v1) sont obsolètes et retournent des erreurs 401/404.
// Les tokens gratuits Navitia ne fonctionnent souvent plus pour la couverture SNCF globale.
// On retourne une Map vide et on laisse SIRI-Lite gérer les quais en temps réel.
return new Map();
}
/** /**
* Récupère et décode les mises à jour temps réel GTFS-RT SNCF * Récupère et décode les mises à jour temps réel GTFS-RT SNCF
*/ */
@@ -157,8 +580,8 @@ async function fetchGtfsRealtimeData() {
try { try {
const [rtRes, alertsRes] = await Promise.allSettled([ const [rtRes, alertsRes] = await Promise.allSettled([
fetchWithTimeout(SNCF_GTFS_RT_TRIP_UPDATES, {}, 6000), fetchWithTimeout(SNCF_GTFS_RT_TRIP_UPDATES, {}, 15000),
fetchWithTimeout(SNCF_GTFS_RT_SERVICE_ALERTS, {}, 6000) fetchWithTimeout(SNCF_GTFS_RT_SERVICE_ALERTS, {}, 15000)
]); ]);
// 1. Décodage Trip Updates (Retards, Suppressions) // 1. Décodage Trip Updates (Retards, Suppressions)
@@ -195,8 +618,26 @@ async function fetchGtfsRealtimeData() {
stopUpdates: stopUpdates stopUpdates: stopUpdates
}; };
const cTrainNo = cleanNum(trainNo);
if (tid) updatesMap.set(tid, updateObj); if (tid) updatesMap.set(tid, updateObj);
if (trainNo) updatesMap.set(trainNo, updateObj); if (trainNo) updatesMap.set(trainNo, updateObj);
if (cTrainNo) updatesMap.set(cTrainNo, updateObj);
stopUpdates.forEach(stu => {
const stopId = String(stu.stopId || '').replace(/\D/g, '');
if (stopId) {
const stopDelay = stu.departure && typeof stu.departure.delay === 'number'
? stu.departure.delay
: (stu.arrival && typeof stu.arrival.delay === 'number' ? stu.arrival.delay : maxDelaySec);
const stopDelayMin = Math.max(0, Math.round(stopDelay / 60));
const stopUpdateObj = {
...updateObj,
delayMinutes: stopDelayMin
};
if (trainNo) updatesMap.set(`${trainNo}_${stopId}`, stopUpdateObj);
if (cTrainNo) updatesMap.set(`${cTrainNo}_${stopId}`, stopUpdateObj);
}
});
} }
}); });
} }
@@ -222,11 +663,13 @@ async function fetchGtfsRealtimeData() {
}); });
} }
gtfsRtCache = { if (updatesMap.size > 0 || alertsList.length > 0) {
timestamp: now, gtfsRtCache = {
updates: updatesMap, timestamp: now,
alerts: alertsList updates: updatesMap,
}; alerts: alertsList
};
}
} catch (err) { } catch (err) {
console.warn('[sncfApi] Notice: GTFS-RT stream refresh error:', err); console.warn('[sncfApi] Notice: GTFS-RT stream refresh error:', err);
} }
@@ -311,28 +754,37 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
const publicUrl = (typeof process !== 'undefined' && process.env && process.env.PUBLIC_URL) ? process.env.PUBLIC_URL : ''; const publicUrl = (typeof process !== 'undefined' && process.env && process.env.PUBLIC_URL) ? process.env.PUBLIC_URL : '';
const stationUrl = `${publicUrl}/data/stations/${uic}.json`; const stationUrl = `${publicUrl}/data/stations/${uic}.json`;
// Chargement parallèle tolérant aux pannes réseau / CORS // Chargement simultané de toutes les données : Horaires + GTFS-RT + SIRI-LITE
const [jsonRes, rtData, siriData] = await Promise.all([ const fetchStationJson = async () => {
fetch(stationUrl).catch(err => { let cached = stationJsonCache.get(String(uic));
if (cached) return cached;
const jsonRes = await fetch(stationUrl).catch(err => {
console.warn(`[sncfApi] Notice: fetch station ${uic} error:`, err); console.warn(`[sncfApi] Notice: fetch station ${uic} error:`, err);
return null; return null;
}), });
fetchGtfsRealtimeData().catch(err => { if (jsonRes && jsonRes.ok) {
console.warn('[sncfApi] Notice: GTFS-RT error:', err); cached = await jsonRes.json();
return { updates: new Map(), alerts: [] }; stationJsonCache.set(String(uic), cached);
}), return cached;
fetchSiriLiteData().catch(err => { }
console.warn('[sncfApi] Notice: SIRI-Lite error:', err); return null;
return { platforms: new Map(), updates: new Map() }; };
})
]);
if (!jsonRes || !jsonRes.ok) { const [stationData] = await Promise.all([
console.warn(`[sncfApi] Fichier de gare introuvable : ${stationUrl} (statut : ${jsonRes ? jsonRes.status : 'réseau bloqué'})`); fetchStationJson(),
triggerRealtimeBackgroundRefresh()
]);
let data = stationData;
if (!data) {
console.warn(`[sncfApi] Fichier de gare introuvable : ${stationUrl}`);
return null; return null;
} }
const rtData = gtfsRtCache || { updates: new Map(), alerts: [] };
const data = await jsonRes.json(); const siriData = siriLiteCache || { platforms: new Map(), updates: new Map() };
const sxData = situationExchangeCache || { platforms: new Map(), updates: new Map(), alerts: [] };
const gareRef = findGtfsGare(uic);
const targetStationNom = (gareRef && gareRef.nom) || (data && data.nom) || '';
const rawList = mode === 'arrivees' ? data.arrivees : data.departs; const rawList = mode === 'arrivees' ? data.arrivees : data.departs;
if (!Array.isArray(rawList) || rawList.length === 0) return null; if (!Array.isArray(rawList) || rawList.length === 0) return null;
@@ -362,21 +814,18 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
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); } catch (_) { customDays = []; }
}
if (Array.isArray(customDays) && customDays.length > 0) {
const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetYYYYMMDD);
if (match) return Boolean(match.circule);
} }
if (customDays.includes(targetDateHyphen)) return true;
} }
// 3. Colonnes circule_lundi, circule_mardi... // 3. Base Supabase : circulation standard par jour de semaine
const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; const daysMap = ['circule_dimanche', 'circule_lundi', 'circule_mardi', 'circule_mercredi', 'circule_jeudi', 'circule_vendredi', 'circule_samedi'];
const colName = `circule_${dayNames[targetDay]}`; const colName = daysMap[targetDay];
if (typeof t[colName] !== 'undefined' && t[colName] !== null) { if (typeof t[colName] !== 'undefined') {
const db = t.date_debut ? String(t.date_debut).slice(0, 10) : null; const dd = t.date_debut ? String(t.date_debut).slice(0, 10) : null;
const df = t.date_fin ? String(t.date_fin).slice(0, 10) : null; const df = t.date_fin ? String(t.date_fin).slice(0, 10) : null;
if (db && targetDateHyphen < db) return false; if (dd && targetDateHyphen < dd) return false;
if (df && targetDateHyphen > df) return false; if (df && targetDateHyphen > df) return false;
return Boolean(t[colName]); return Boolean(t[colName]);
} }
@@ -399,21 +848,39 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
return true; return true;
}; };
// Helper de résolution de quai // Helper de résolution de quai en temps réel (Estimated Timetable + Situation Exchange + OpenData)
const lookupPlatform = (trainNum, tripId, stationUic, stationName) => { const lookupPlatform = (trainNum, tripId, stationUic, stationName, rawTimeStr = '', destName = '') => {
const cNum = cleanNum(trainNum); const cNum = cleanNum(trainNum);
const rawNum = String(trainNum || '').replace(/\D/g, '');
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() : '';
const cDest = destName ? formatStationName(destName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : '';
const uicClean = String(stationUic || '').replace(/\D/g, '');
const uic7 = (uicClean.length === 8 && uicClean.startsWith('87')) ? uicClean.slice(2) : '';
return ( return (
(cNum && stationUic && siriData.platforms.get(`${cNum}_${stationUic}`)) || // 1. SIRI-Lite Estimated Timetable (60 prochaines min - Priorité 1)
(trainNum && stationUic && siriData.platforms.get(`${trainNum}_${stationUic}`)) || (cNum && uicClean && siriData.platforms.get(`${cNum}_${uicClean}`)) ||
(stationUic && cNum && siriData.platforms.get(`${stationUic}_${cNum}`)) || (rawNum && uicClean && siriData.platforms.get(`${rawNum}_${uicClean}`)) ||
(stationUic && trainNum && siriData.platforms.get(`${stationUic}_${trainNum}`)) || (uicClean && cNum && siriData.platforms.get(`${uicClean}_${cNum}`)) ||
(cNum && uic7 && siriData.platforms.get(`${cNum}_${uic7}`)) ||
(uic7 && cNum && siriData.platforms.get(`${uic7}_${cNum}`)) ||
(cNum && cName && siriData.platforms.get(`${cNum}_${cName}`)) || (cNum && cName && siriData.platforms.get(`${cNum}_${cName}`)) ||
(trainNum && cName && siriData.platforms.get(`${trainNum}_${cName}`)) || (rawNum && cName && siriData.platforms.get(`${rawNum}_${cName}`)) ||
(tripId && stationUic && siriData.platforms.get(`${tripId}_${stationUic}`)) || (tripId && uicClean && siriData.platforms.get(`${tripId}_${uicClean}`)) ||
(stationUic && tripId && siriData.platforms.get(`${stationUic}_${tripId}`)) ||
(tripId && cName && siriData.platforms.get(`${tripId}_${cName}`)) || (tripId && cName && siriData.platforms.get(`${tripId}_${cName}`)) ||
// 2. SIRI-Lite Situation Exchange (Circulations à venir / Changements de quai)
(cNum && uicClean && sxData.platforms.get(`${cNum}_${uicClean}`)) ||
(rawNum && uicClean && sxData.platforms.get(`${rawNum}_${uicClean}`)) ||
(uicClean && cNum && sxData.platforms.get(`${uicClean}_${cNum}`)) ||
(cNum && cName && sxData.platforms.get(`${cNum}_${cName}`)) ||
(rawNum && cName && sxData.platforms.get(`${rawNum}_${cName}`)) ||
// 3. Fallback sur le numéro de train seul si connu dans SIRI-Lite / Situation Exchange
(cNum && siriData.platforms.get(cNum)) ||
(rawNum && siriData.platforms.get(rawNum)) ||
(cNum && siriData.updates.get(cNum)?.platform) ||
(rawNum && siriData.updates.get(rawNum)?.platform) ||
(cNum && sxData.updates.get(cNum)?.platform) ||
(rawNum && sxData.updates.get(rawNum)?.platform) ||
'' ''
); );
}; };
@@ -423,17 +890,41 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
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 (valide pour aujourd'hui) const stopUicClean = String(uic || '').replace(/\D/g, '');
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 stopUic7 = (stopUicClean.length === 8 && stopUicClean.startsWith('87')) ? stopUicClean.slice(2) : '';
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 cTrainNum = cleanNum(t.num);
const isCanceled = (rt && rt.isCanceled) || (siri && siri.isCanceled) || false; // Recherche de mise à jour GTFS-RT, SIRI-Lite Timetable ou SIRI-Lite Situation Exchange
const rt = !isTomorrow ? (
(stopUicClean && cTrainNum && rtData.updates.get(`${cTrainNum}_${stopUicClean}`)) ||
(stopUicClean && t.num && rtData.updates.get(`${t.num}_${stopUicClean}`)) ||
(stopUic7 && cTrainNum && rtData.updates.get(`${cTrainNum}_${stopUic7}`)) ||
rtData.updates.get(t.trip_id) ||
(t.num ? rtData.updates.get(t.num) : null) ||
(cTrainNum ? rtData.updates.get(cTrainNum) : null)
) : null;
const siri = !isTomorrow ? (siriData.updates.get(t.num) || (cleanNum(t.num) ? siriData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? siriData.updates.get(t.trip_id) : null)) : null;
const sx = !isTomorrow ? (sxData.updates.get(t.num) || (cleanNum(t.num) ? sxData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? sxData.updates.get(t.trip_id) : null)) : null;
const isCanceled = (rt && rt.isCanceled) || (siri && siri.isCanceled) || (sx && sx.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);
const currentStationName = mode === 'arrivees' ? t.dest : t.orig; const currentStationName = targetStationNom || (data && data.nom) || (mode === 'arrivees' ? t.dest : t.orig);
let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName); const destinationStation = mode === 'arrivees' ? t.orig : t.dest;
if (!realVoie && t.quai && typeof t.quai === 'string') { const isAutocar = (
realVoie = t.quai.trim(); (t.type && (t.type.toUpperCase().includes('AUTOCAR') || t.type.toUpperCase().includes('CAR') || t.type.toUpperCase() === 'BUS')) ||
(t.type_train && (t.type_train.toUpperCase().includes('AUTOCAR') || t.type_train.toUpperCase().includes('CAR') || t.type_train.toUpperCase() === 'BUS')) ||
(t.op && (t.op.toUpperCase().includes('AUTOCAR') || t.op.toUpperCase().includes('CAR') || t.op.toUpperCase() === 'BUS')) ||
Boolean(t.is_substitution) ||
isCarNumber(t.num || t.numero_train || t.numero)
);
let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName, rawTime, destinationStation);
if (!realVoie && (t.quai || t.quai_reel || t.platform || t.voie)) {
realVoie = String(t.quai || t.quai_reel || t.platform || t.voie).replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
}
if (!realVoie) {
realVoie = isAutocar ? 'AUTOCAR' : resolveNominalTrack(currentStationName, t.num, uic);
} }
let statusLabel = "à l'heure"; let statusLabel = "à l'heure";
@@ -442,18 +933,34 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
statusLabel = 'supprimé'; statusLabel = 'supprimé';
etat = 'supprime'; etat = 'supprime';
} else if (delayMinutes > 0) { } else if (delayMinutes > 0) {
statusLabel = `retardé +${delayMinutes}min.`; if (delayMinutes >= 60) {
const h = Math.floor(delayMinutes / 60);
const rem = delayMinutes % 60;
statusLabel = rem > 0 ? `retardé +${h}h${String(rem).padStart(2, '0')}` : `retardé +${h}h`;
} else {
statusLabel = `retardé +${delayMinutes}min.`;
}
etat = 'retarde'; etat = 'retarde';
} }
let cause = ''; let cause = (sx && sx.cause) || '';
if (!isTomorrow && delayMinutes > 0 && rtData.alerts.length > 0) { if (!isTomorrow && delayMinutes > 0 && !cause) {
cause = rtData.alerts[idx % rtData.alerts.length].text; if (rtData.alerts.length > 0) {
cause = rtData.alerts[idx % rtData.alerts.length].text;
} else if (sxData.alerts.length > 0) {
cause = sxData.alerts[idx % sxData.alerts.length].text;
}
} }
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); let stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom, st.heure, destinationStation);
if (!stopVoie && (st.quai || st.quai_reel || st.voie || st.platform)) {
stopVoie = String(st.quai || st.quai_reel || st.voie || st.platform).replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
}
if (!stopVoie) {
stopVoie = isAutocar ? 'AUTOCAR' : resolveNominalTrack(st.nom, t.num);
}
return { return {
uic: stopUic, uic: stopUic,
@@ -550,6 +1057,14 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
resultList = remainingToday; resultList = remainingToday;
} }
if (resultList.length === 0) {
resultList = (enrichedToday.length > 0 ? enrichedToday : rawList.map((t, idx) => mapTrain(t, idx, false))).sort((a, b) => {
const aHM = (mode === 'arrivees' ? a._arrHM : a._depHM) || 0;
const bHM = (mode === 'arrivees' ? b._arrHM : b._depHM) || 0;
return aHM - bHM;
});
}
return resultList; return resultList;
} catch (err) { } catch (err) {
console.warn('[sncfApi] Erreur de chargement GTFS station:', err); console.warn('[sncfApi] Erreur de chargement GTFS station:', err);
@@ -572,13 +1087,10 @@ export async function getSncfTimetable({ gareName, mode = 'departs', quai = null
let finalTrains = null; let finalTrains = null;
if (uic) { if (uic) {
finalTrains = await fetchGtfsStationTimetable(uic, mode); finalTrains = await fetchGtfsStationTimetable(uic, mode);
} } else {
// Si UIC non trouvé directement, chercher par nom
// 2. Si non trouvé ou gare sans fichier, chercher avec le nom const match = findGtfsGare(gareName);
if (!finalTrains || finalTrains.length === 0) { if (match && match.uic) {
const rawGare = gare ? gare.nom : gareName;
const match = findGtfsGare(rawGare);
if (match && match.uic && match.uic !== uic) {
finalTrains = await fetchGtfsStationTimetable(match.uic, mode); finalTrains = await fetchGtfsStationTimetable(match.uic, mode);
} }
} }
@@ -587,19 +1099,14 @@ export async function getSncfTimetable({ gareName, mode = 'departs', quai = null
finalTrains = []; finalTrains = [];
} }
// 3. Filtrage par quai si demandé // 3. Filtrage strict par quai si demandé
if (quai) { if (quai) {
const normQuai = String(quai).trim().toLowerCase(); const cleanP = (p) => String(p || '').toLowerCase().replace(/^(?:voie|quai|track|platform)\s*/i, '').replace(/\s+/g, '').trim();
const matching = finalTrains.filter(t => String(t.quai || t.voie).trim().toLowerCase() === normQuai); const targetP = cleanP(quai);
if (matching.length > 0) { finalTrains = finalTrains.filter(t => {
finalTrains = matching; const q = cleanP(t.quai || t.voie || t.quai_reel || t._displayPlatform);
} else if (finalTrains.length > 0) { return Boolean(q && q === targetP);
finalTrains[0].quai = quai; });
finalTrains[0].voie = quai;
finalTrains[0].quai_reel = quai;
finalTrains[0]._displayPlatform = quai;
finalTrains = [finalTrains[0]];
}
} }
return { data: finalTrains, error: null }; return { data: finalTrains, error: null };
+935
View File
@@ -0,0 +1,935 @@
/**
* Moteur de classification et de corrélation des types de trains et opérateurs ferroviaires
* ferrovia-infogare - Compatible afficheurs classiques (Hall & Quai)
*
* Hiérarchie de décision :
* 1. Codes d'exploitation GTFS officiels (trip_id SNCF Open Data)
* 2. Détection des services spécifiques (Lyria, Ouigo Classique, Intercités de Nuit, etc.)
* 3. Plan de Numérotation officiel SNCF (séries régionales TER hermétiques par blocs de numéros)
* 4. Détection géographique par score d'arrêts et de terminus (sans faux-positifs)
* 5. Re-routage vers TER générique ou SNCF Voyageurs en dernier recours
*/
// Normalisation des chaînes (minuscules, sans accents, espaces normalisés)
export const normalize = (str) => {
if (!str) return '';
return String(str)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
};
// Extraction des chiffres d'un numéro de train (sans zéros initiaux)
export const extractTrainDigits = (num) => {
if (!num) return '';
const digits = String(num).replace(/\D/g, '');
return digits.replace(/^0+/, '') || digits;
};
// Services routiers par autocar (3xxxx / 3xxxxx)
export const isCarTrainNumber = (num) => {
const d = String(num || '').replace(/\D/g, '');
return (d.length === 5 && d.startsWith('3')) || (d.length === 6 && d.startsWith('3'));
};
/**
* Référentiel des marques, types de trains et logos (monochrome Hall & couleur Quai)
*/
export const BRANDS = {
INOUi: {
id: 'inoui',
typeLabel: 'TGV inOUI',
quaiLabel: 'TGV INOUI',
operator: 'SNCF Voyageurs',
category: 'TGV',
isTgv: true,
hallLogo: '/type/logo-inoui.svg',
quaiLogo: '/type/quais/logo-inoui-color.svg',
showVoyageursText: false
},
TGV_GENERIC: {
id: 'tgv',
typeLabel: 'TGV',
quaiLabel: 'TGV',
operator: 'SNCF Voyageurs',
category: 'TGV',
isTgv: true,
hallLogo: '/type/logo-tgv.svg',
quaiLogo: '/type/quais/logo-tgv-color.svg',
showVoyageursText: false
},
LYRIA: {
id: 'lyria',
typeLabel: 'TGV Lyria',
quaiLabel: 'TGV Lyria',
operator: 'Lyria SAS',
category: 'TGV',
isTgv: true,
hallLogo: '/type/logo-lyria.svg',
quaiLogo: '/type/quais/logo-lyria-color.svg',
showVoyageursText: false
},
OUIGO_CLASSIQUE: {
id: 'ouigo-classique',
typeLabel: 'OUIGO Train Classique',
quaiLabel: 'OUIGO Classique',
operator: 'SNCF Voyageurs',
category: 'GRANDES_LIGNES',
isTgv: false,
hallLogo: '/type/logo-ouigo-classique.svg',
quaiLogo: '/type/quais/logo-ouigo-classique-color.svg',
showVoyageursText: false
},
OUIGO_GV: {
id: 'ouigo',
typeLabel: 'OUIGO',
quaiLabel: 'OUIGO',
operator: 'SNCF Voyageurs',
category: 'TGV',
isTgv: true,
hallLogo: '/type/logo-ouigo.svg',
quaiLogo: '/type/quais/logo-ouigo-color.svg',
showVoyageursText: false
},
INTERCITES_NUIT: {
id: 'intercites-nuit',
typeLabel: 'INTERCITÉS DE NUIT',
quaiLabel: 'Intercités de Nuit',
operator: 'SNCF Voyageurs',
category: 'GRANDES_LIGNES',
isTgv: false,
hallLogo: '/type/logo-lunea.svg',
quaiLogo: '/type/quais/logo-lunea-color.svg',
showVoyageursText: false
},
INTERCITES: {
id: 'intercites',
typeLabel: 'INTERCITÉS',
quaiLabel: 'INTERCITÉS',
operator: 'SNCF Voyageurs',
category: 'GRANDES_LIGNES',
isTgv: false,
hallLogo: '/type/logo-intercites.svg',
quaiLogo: '/type/quais/logo-intercite-color.svg',
showVoyageursText: false
},
EUROSTAR: {
id: 'eurostar',
typeLabel: 'EUROSTAR',
quaiLabel: 'Eurostar',
operator: 'Eurostar Group',
category: 'INTERNATIONAL',
isTgv: true,
hallLogo: '/type/logo-eurostar.svg',
quaiLogo: '/type/quais/logo-eurostar-color.svg',
showVoyageursText: false
},
THALYS: {
id: 'thalys',
typeLabel: 'THALYS',
quaiLabel: 'Thalys',
operator: 'Eurostar Group',
category: 'INTERNATIONAL',
isTgv: true,
hallLogo: '/type/logo-thalys.svg',
quaiLogo: '/type/quais/logo-thalys-color.svg',
showVoyageursText: false
},
ICE: {
id: 'ice',
typeLabel: 'ICE',
quaiLabel: 'ICE',
operator: 'DB / SNCF',
category: 'INTERNATIONAL',
isTgv: true,
hallLogo: '/type/logo-ice.svg',
quaiLogo: '/type/quais/logo-ice-color.svg',
showVoyageursText: false
},
DB: {
id: 'db',
typeLabel: 'DB',
quaiLabel: 'DB',
operator: 'Deutsche Bahn',
category: 'INTERNATIONAL',
isTgv: false,
hallLogo: '/type/logo-db.svg',
quaiLogo: '/type/quais/logo-db-color.svg',
showVoyageursText: false
},
FRECCIAROSSA: {
id: 'frecciarossa',
typeLabel: 'FRECCIAROSSA',
quaiLabel: 'Frecciarossa',
operator: 'Trenitalia France',
category: 'INTERNATIONAL',
isTgv: true,
hallLogo: '/type/logo-frecciarossa.svg',
quaiLogo: '/type/quais/logo-frecciarossa-color.svg',
showVoyageursText: false
},
RENFE_AVE: {
id: 'renfe-ave',
typeLabel: 'Renfe AVE',
quaiLabel: 'Renfe AVE',
operator: 'Renfe',
category: 'INTERNATIONAL',
isTgv: true,
hallLogo: '/type/logo-renfeave.svg',
quaiLogo: '/type/quais/logo-renfeave-color.svg',
showVoyageursText: false
},
AUTOCAR: {
id: 'autocar',
typeLabel: 'AUTOCAR',
quaiLabel: 'Autocar',
operator: 'SNCF Voyageurs',
category: 'AUTOCAR',
isTgv: false,
hallLogo: '/type/track-car.svg',
quaiLogo: '/type/track-car.svg',
showVoyageursText: false
},
TRAMTRAIN: {
id: 'tramtrain',
typeLabel: 'Tram-train',
quaiLabel: 'Tram-train',
operator: 'TER',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-tramtrain.svg',
quaiLogo: '/type/quais/logo-tramtrain-color.svg',
showVoyageursText: false
},
MOBIGO: {
id: 'mobigo',
typeLabel: 'MOBIGO',
quaiLabel: 'Mobigo',
operator: 'Région Bourgogne-Franche-Comté',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-mobigo.svg',
quaiLogo: '/type/quais/logo-mobigo-color.svg',
showVoyageursText: false
},
ZOU: {
id: 'zou',
typeLabel: 'ZOU !',
quaiLabel: 'ZOU!',
operator: 'Région Sud / PACA',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-zou.svg',
quaiLogo: '/type/quais/logo-zou-color.svg',
showVoyageursText: false
},
FLUO: {
id: 'fluo',
typeLabel: 'FLUO',
quaiLabel: 'TER Fluo',
operator: 'Région Grand Est',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-fluo.svg',
quaiLogo: '/type/quais/logo-fluo-color.svg',
showVoyageursText: false
},
ALEOP: {
id: 'aleop',
typeLabel: 'ALÉOP',
quaiLabel: 'Aléop',
operator: 'Région Pays de la Loire',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-aleop.svg',
quaiLogo: '/type/quais/logo-aleop-color.svg',
showVoyageursText: false
},
BREIZHGO: {
id: 'breizhgo',
typeLabel: 'BREIZHGO',
quaiLabel: 'BreizhGo',
operator: 'Région Bretagne',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-breizhgo.svg',
quaiLogo: '/type/quais/logo-breizhgo-color.svg',
showVoyageursText: false
},
NOMAD: {
id: 'nomad',
typeLabel: 'NOMAD',
quaiLabel: 'Nomad',
operator: 'Région Normandie',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-nomad.svg',
quaiLogo: '/type/quais/logo-nomad-color.svg',
showVoyageursText: false
},
REMI: {
id: 'remi',
typeLabel: 'RÉMI',
quaiLabel: 'Rémi',
operator: 'Région Centre-Val de Loire',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-remi.svg',
quaiLogo: '/type/quais/logo-remi-color.svg',
showVoyageursText: false
},
LIO: {
id: 'lio',
typeLabel: 'LIÒ',
quaiLabel: 'liO',
operator: 'Région Occitanie',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-lio.svg',
quaiLogo: '/type/quais/logo-lio-color.svg',
showVoyageursText: false
},
HAUTS_DE_FRANCE: {
id: 'hautsdefrance',
typeLabel: 'HAUTS-DE-FRANCE',
quaiLabel: 'Hauts-de-France',
operator: 'Région Hauts-de-France',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-hautsdefrance.svg',
quaiLogo: '/type/quais/logo-hautsdefrance-color.svg',
showVoyageursText: false
},
AUVERGNE_RHONE_ALPES: {
id: 'auvergerhonealpes',
typeLabel: 'AUVERGNE-RHÔNE-ALPES',
quaiLabel: 'Auvergne-Rhône-Alpes',
operator: 'Région Auvergne-Rhône-Alpes',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-auvergerhonealpes.svg',
quaiLogo: '/type/quais/logo-auvergerhonealpes-color.svg',
showVoyageursText: false
},
NOUVELLE_AQUITAINE: {
id: 'nouvelleaquitaine',
typeLabel: 'NOUVELLE-AQUITAINE',
quaiLabel: 'Nouvelle-Aquitaine',
operator: 'Région Nouvelle-Aquitaine',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-teraquitaine.svg',
quaiLogo: '/type/quais/logo-nouvelleaquitaine-color.svg',
showVoyageursText: false
},
TER_GENERIC: {
id: 'ter',
typeLabel: 'TER',
quaiLabel: 'TER',
operator: 'SNCF Voyageurs',
category: 'TER',
isTgv: false,
hallLogo: '/type/logo-ter.svg',
quaiLogo: '/type/quais/logo-ter-color.svg',
showVoyageursText: false
},
SBB: {
id: 'sbb',
typeLabel: 'SBB',
quaiLabel: 'SBB',
operator: 'CFF / SBB',
category: 'INTERNATIONAL',
isTgv: false,
hallLogo: '/type/logo-sbb.svg',
quaiLogo: '/type/quais/logo-sbb-color.svg',
showVoyageursText: false
},
SNCB: {
id: 'sncb',
typeLabel: 'SNCB',
quaiLabel: 'SNCB',
operator: 'SNCB / NMBS',
category: 'INTERNATIONAL',
isTgv: false,
hallLogo: '/type/logo-sncb.svg',
quaiLogo: '/type/quais/logo-sncb-color.svg',
showVoyageursText: false
},
CFL: {
id: 'cfl',
typeLabel: 'CFL',
quaiLabel: 'CFL',
operator: 'CFL',
category: 'INTERNATIONAL',
isTgv: false,
hallLogo: '/type/logo-cfl.svg',
quaiLogo: '/type/quais/logo-cfl-color.svg',
showVoyageursText: false
},
SNCF_GENERIC: {
id: 'sncf',
typeLabel: 'SNCF',
quaiLabel: 'SNCF',
operator: 'SNCF Voyageurs',
category: 'SNCF',
isTgv: false,
hallLogo: '/type/logo-sncf.svg',
quaiLogo: '/type/quais/logo-sncf-color.svg',
showVoyageursText: true
}
};
/**
* Plan de Numérotation officiel SNCF (Séries régionales TER)
* Chaque autorité organisatrice régionale dispose de tranches numériques réservées.
*/
function getRegionByTrainNumber(numStr) {
const clean = String(numStr || '').replace(/\D/g, '');
if (!clean || clean.length < 3) return null;
// 1. Occitanie (liO) : 869xxx, 870xxx, 871xxx, 872xxx, 876xxx, 877xxx (et 5 chiffres : 869xx, 870xx, 876xx, 877xx)
if (
clean.startsWith('869') ||
clean.startsWith('870') ||
clean.startsWith('871') ||
clean.startsWith('872') ||
clean.startsWith('876') ||
clean.startsWith('877')
) {
return BRANDS.LIO;
}
// 2. Centre-Val de Loire (Rémi) : 860xxx, 861xxx, 862xxx, 863xxx (et 860xx à 863xx)
if (
clean.startsWith('860') ||
clean.startsWith('861') ||
clean.startsWith('862') ||
clean.startsWith('863')
) {
return BRANDS.REMI;
}
// 3. Nouvelle-Aquitaine : 864xxx, 865xxx, 866xxx, 867xxx, 868xxx (et 864xx à 868xx)
if (
clean.startsWith('864') ||
clean.startsWith('865') ||
clean.startsWith('866') ||
clean.startsWith('867') ||
clean.startsWith('868')
) {
return BRANDS.NOUVELLE_AQUITAINE;
}
// 4. Grand Est (Fluo) : 83xxxx (et 83xxx)
if (clean.startsWith('83')) {
return BRANDS.FLUO;
}
// 5. Bourgogne-Franche-Comté (Mobigo) : 89xxxx, 840xxx, 841xxx (et 89xxx, 840xx, 841xx)
if (
clean.startsWith('89') ||
clean.startsWith('840') ||
clean.startsWith('841')
) {
return BRANDS.MOBIGO;
}
// 6. Hauts-de-France : 842xxx à 849xxx (et 842xx à 849xx)
if (['842', '843', '844', '845', '846', '847', '848', '849'].some(p => clean.startsWith(p))) {
return BRANDS.HAUTS_DE_FRANCE;
}
// 7. Normandie (Nomad) : 852xxx, 853xxx (et 852xx, 853xx)
if (clean.startsWith('852') || clean.startsWith('853')) {
return BRANDS.NOMAD;
}
// 8. Bretagne (BreizhGo) : 854xxx, 855xxx, 856xxx (et 854xx à 856xx)
if (clean.startsWith('854') || clean.startsWith('855') || clean.startsWith('856')) {
return BRANDS.BREIZHGO;
}
// 9. Pays de la Loire (Aléop) : 857xxx, 858xxx, 859xxx (et 857xx à 859xx)
if (clean.startsWith('857') || clean.startsWith('858') || clean.startsWith('859')) {
return BRANDS.ALEOP;
}
// 10. Auvergne-Rhône-Alpes : 873xxx à 875xxx, 883xxx, 884xxx, 889xxx
if (['873', '874', '875', '883', '884', '889'].some(p => clean.startsWith(p))) {
return BRANDS.AUVERGNE_RHONE_ALPES;
}
// 11. PACA / Sud (Zou !) : 880xxx, 881xxx, 882xxx, 885xxx, 886xxx, 887xxx, 888xxx
if (['880', '881', '882', '885', '886', '887', '888'].some(p => clean.startsWith(p))) {
return BRANDS.ZOU;
}
return null;
}
/**
* Gares de référence par région ferroviaire pour analyse de parcours
* (Seuls des noms de villes ou gares réelles sans mots génériques comme 'centre' ou 'est')
*/
const REGIONAL_STATIONS = {
lio: [
'perpignan', 'narbonne', 'toulouse', 'nimes', 'beziers', 'sete', 'montpellier',
'cerbere', 'portbou', 'villefranche', 'carcassonne', 'agde', 'elne', 'argeles',
'collioure', 'port vendres', 'banyuls', 'rodez', 'albi', 'tarbes', 'montauban',
'castres', 'lourdes', 'mende', 'foix', 'latour de carol', 'ax les thermes',
'castelnaudary', 'frontignan', 'port la nouvelle', 'leucate', 'salses', 'rivesaltes'
],
remi: [
'orleans', 'tours', 'bourges', 'blois', 'chartres', 'chateauroux', 'vierzon',
'vendome', 'saint pierre des corps', 'amboise', 'nogent le rotrou', 'salbris',
'chinon', 'loches', 'issoudun', 'romorantin', 'montargis', 'gien'
],
mobigo: [
'dijon', 'besancon', 'belfort', 'auxerre', 'nevers', 'chalon sur saone',
'montbeliard', 'macon', 'sens', 'dole', 'creusot', 'paray le monial',
'beaune', 'lons le saunier', 'pontarlier', 'vesoul'
],
zou: [
'marseille', 'nice', 'toulon', 'cannes', 'antibes', 'avignon', 'grasse',
'aubagne', 'miramas', 'arles', 'hyeres', 'menton', 'cagnes sur mer',
'frejus', 'saint raphael', 'la ciotat', 'gap', 'briancon', 'manosque'
],
fluo: [
'strasbourg', 'nancy', 'metz', 'mulhouse', 'colmar', 'reims', 'troyes',
'epinal', 'thionville', 'charleville', 'saint die', 'haguenau',
'sarreguemines', 'bar le duc', 'luneville', 'sedan', 'forbach', 'selestat'
],
aleop: [
'nantes', 'angers', 'le mans', 'saint nazaire', 'cholet', 'la roche sur yon',
'saumur', 'laval', 'les sables d olonne', 'ancenis', 'clisson', 'chateaubriant'
],
breizhgo: [
'rennes', 'brest', 'quimper', 'saint malo', 'vannes', 'lorient',
'saint brieuc', 'morlaix', 'lannion', 'vitre', 'redon', 'guingamp'
],
nomad: [
'rouen', 'caen', 'le havre', 'cherbourg', 'evreux', 'trouville deauville',
'dieppe', 'lisieux', 'granville', 'alencon', 'bernay', 'bayeux'
],
hautsdefrance: [
'lille', 'amiens', 'calais', 'dunkerque', 'valenciennes', 'arras',
'boulogne sur mer', 'douai', 'creil', 'saint quentin', 'lens', 'bethune',
'compiegne', 'cambrai', 'tourcoing', 'roubaix'
],
auvergerhonealpes: [
'lyon', 'grenoble', 'chambery', 'annecy', 'valence', 'clermont ferrand',
'saint etienne', 'bourg en bresse', 'roanne', 'vichy', 'aix les bains',
'annemasse', 'amberieu', 'riom', 'moulins'
],
nouvelleaquitaine: [
'bordeaux', 'limoges', 'poitiers', 'la rochelle', 'bayonne', 'pau',
'angouleme', 'perigueux', 'agen', 'niort', 'brive la gaillarde', 'dax',
'hendaye', 'saintes', 'arcachon'
]
};
/**
* Calcule la région la plus probable selon les arrêts desservis et le terminus
*/
function detectRegionByRouteScore(routeText) {
if (!routeText) return null;
const normalized = ` ${normalize(routeText)} `;
let bestRegion = null;
let maxScore = 0;
for (const [regionKey, stations] of Object.entries(REGIONAL_STATIONS)) {
let score = 0;
for (const station of stations) {
const stationNorm = ` ${normalize(station)} `;
if (normalized.includes(stationNorm)) {
score++;
}
}
if (score > maxScore) {
maxScore = score;
bestRegion = regionKey;
}
}
if (maxScore > 0 && bestRegion) {
switch (bestRegion) {
case 'lio': return BRANDS.LIO;
case 'remi': return BRANDS.REMI;
case 'mobigo': return BRANDS.MOBIGO;
case 'zou': return BRANDS.ZOU;
case 'fluo': return BRANDS.FLUO;
case 'aleop': return BRANDS.ALEOP;
case 'breizhgo': return BRANDS.BREIZHGO;
case 'nomad': return BRANDS.NOMAD;
case 'hautsdefrance': return BRANDS.HAUTS_DE_FRANCE;
case 'auvergerhonealpes': return BRANDS.AUVERGNE_RHONE_ALPES;
case 'nouvelleaquitaine': return BRANDS.NOUVELLE_AQUITAINE;
default: return null;
}
}
return null;
}
/**
* Fonction centrale de classification d'un train à partir de ses données SNCF
* @param {Object|string} trainOrType - Objet horaire ou type brut
* @param {string} [operatorParam] - Opérateur ou transporteur optionnel
* @param {string} [numParam] - Numéro de train optionnel
* @returns {Object} Résultat complet de classification
*/
export function classifyTrain(trainOrType, operatorParam = '', numParam = '') {
let horaire = null;
let rawType = '';
let opRaw = operatorParam || '';
let numRaw = numParam || '';
if (trainOrType && typeof trainOrType === 'object') {
horaire = trainOrType;
rawType = horaire.type_train || horaire.type || horaire.train_type || horaire.commercial_mode || horaire.mode || '';
opRaw = horaire.operator || horaire.op || horaire.transporteur || horaire.carrier || opRaw;
numRaw = horaire.numero_train || horaire.numero || horaire.number || horaire.num || horaire.trip_short_name || numRaw;
} else {
rawType = String(trainOrType || '');
}
const numTrain = extractTrainDigits(numRaw);
const numInt = parseInt(numTrain, 10) || 0;
const tripId = horaire ? String(horaire.trip_id || '') : '';
const isSubstitution = Boolean(horaire && (horaire.is_substitution || horaire.is_sub));
const origName = horaire ? (horaire.gare_depart_nom || horaire.orig || horaire.origin || '') : '';
const destName = horaire ? (horaire.gare_arrivee_nom || horaire.dest || horaire.destination || '') : '';
const stopsText = Array.isArray(horaire?.stops) ? horaire.stops.map(s => (s.nom || s.gare_nom || s.name || '')).join(' ') : '';
const fullRouteText = `${origName} ${destName} ${stopsText}`;
const routeNormalized = normalize(fullRouteText);
const typeNorm = normalize(rawType);
const opNorm = normalize(opRaw);
const combined = `${typeNorm} ${opNorm}`.trim();
// Helper pour créer le résultat enrichi
const buildResult = (brand, customProps = {}) => ({
...brand,
numTrain,
rawType,
rawOperator: opRaw,
...customProps
});
// =========================================================================
// 1. SERVICES AUTOCAR / CAR TER / BUS DE SUBSTITUTION
// =========================================================================
if (
isSubstitution ||
isCarTrainNumber(numTrain) ||
combined.includes('autocar') ||
combined.includes('carter') ||
combined.includes('substitution') ||
combined === 'car' ||
combined === 'bus'
) {
return buildResult(BRANDS.AUTOCAR);
}
// =========================================================================
// 2. TGV LYRIA (Paris - Suisse : Genève, Lausanne, Zurich, Bâle)
// =========================================================================
if (
combined.includes('lyria') ||
tripId.includes(':LYR:FR') ||
(numInt >= 9200 && numInt <= 9299) ||
(numInt >= 9750 && numInt <= 9799)
) {
return buildResult(BRANDS.LYRIA);
}
// =========================================================================
// 3. OUIGO TRAIN CLASSIQUE (4000-4399 ou 8040-8099, Paris Austerlitz/Bercy)
// =========================================================================
const isOuigoClassique = (
combined.includes('ouigoclassique') ||
combined.includes('trainclassique') ||
combined.includes('classique') ||
(combined.includes('ouigo') && ((numInt >= 4000 && numInt <= 4399) || (numInt >= 8040 && numInt <= 8099))) ||
(tripId.includes(':OGO:FR') && ((numInt >= 4000 && numInt <= 4399) || (numInt >= 8040 && numInt <= 8099)))
);
if (isOuigoClassique) {
return buildResult(BRANDS.OUIGO_CLASSIQUE);
}
// =========================================================================
// 4. OUIGO (GRANDE VITESSE)
// =========================================================================
if (
combined.includes('ouigo') ||
tripId.includes(':OGO:FR') ||
(numInt >= 7600 && numInt <= 7999)
) {
return buildResult(BRANDS.OUIGO_GV);
}
// =========================================================================
// 5. INTERCITÉS DE NUIT (Lunéa)
// =========================================================================
if (
combined.includes('denuit') ||
combined.includes('nuit') ||
combined.includes('lunea') ||
tripId.includes(':ICN:FR') ||
(numInt >= 3690 && numInt <= 3799) ||
(numInt >= 3950 && numInt <= 3999) ||
(numInt >= 5700 && numInt <= 5799)
) {
return buildResult(BRANDS.INTERCITES_NUIT);
}
// =========================================================================
// 6. INTERCITÉS (Trains d'Équilibre du Territoire de jour)
// =========================================================================
if (
combined.includes('intercites') ||
combined === 'ic' ||
combined.startsWith('ic ') ||
tripId.includes(':IC:FR') ||
(numInt >= 3000 && numInt <= 3949) ||
(numInt >= 4400 && numInt <= 4999)
) {
return buildResult(BRANDS.INTERCITES);
}
// =========================================================================
// 7. EUROSTAR & THALYS
// =========================================================================
const isEurostarOrThalys = (
combined.includes('eurostar') ||
combined.includes('thalys') ||
(tripId.includes(':TRN:FR') && (routeNormalized.includes('bruxelles') || routeNormalized.includes('london') || routeNormalized.includes('amsterdam') || routeNormalized.includes('koln'))) ||
((numInt >= 60 && numInt <= 99) && (routeNormalized.includes('bruxelles') || routeNormalized.includes('nord'))) ||
(numInt >= 9000 && numInt <= 9499 && (routeNormalized.includes('bruxelles') || routeNormalized.includes('london') || routeNormalized.includes('amsterdam')))
);
if (isEurostarOrThalys) {
if (combined.includes('thalys')) {
return buildResult(BRANDS.THALYS);
}
return buildResult(BRANDS.EUROSTAR);
}
// =========================================================================
// 8. FRECCIAROSSA / TRENITALIA (Paris Lyon - Lyon - Milan)
// =========================================================================
if (
combined.includes('frecciarossa') ||
combined.includes('freccia') ||
combined.includes('trenitalia') ||
(numInt >= 6640 && numInt <= 6670)
) {
return buildResult(BRANDS.FRECCIAROSSA);
}
// =========================================================================
// 9. RENFE AVE (Marseille / Lyon - Madrid / Barcelone)
// =========================================================================
if (
combined.includes('renfe') ||
combined.includes('ave') ||
[9724, 9725, 9731, 9732].includes(numInt)
) {
return buildResult(BRANDS.RENFE_AVE);
}
// =========================================================================
// 10. ICE / DB (Paris Est - Allemagne)
// =========================================================================
if (
combined.includes('ice') ||
combined === 'db' ||
tripId.includes(':ICE:FR') ||
(numInt >= 9550 && numInt <= 9599)
) {
return buildResult(BRANDS.ICE);
}
// =========================================================================
// 11. TGV INOUI & TGV GÉNÉRIQUE
// =========================================================================
if (
combined.includes('inoui') ||
combined.includes('tgvinoui') ||
tripId.includes(':OUI:FR') ||
combined.includes('tgv')
) {
return buildResult(BRANDS.INOUi);
}
// =========================================================================
// 12. TRAM-TRAIN
// =========================================================================
if (
combined.includes('tramtrain') ||
combined.includes('tram-train') ||
tripId.includes(':TT:FR')
) {
return buildResult(BRANDS.TRAMTRAIN);
}
// =========================================================================
// 13. TERGV
// =========================================================================
if (combined.includes('tergv') || combined.includes('ter-gv')) {
return buildResult(BRANDS.HAUTS_DE_FRANCE, { typeLabel: 'TERGV', quaiLabel: 'TERGV' });
}
// =========================================================================
// 14. OPÉRATEURS SUISSE, BELGE, LUXEMBOURGEOIS
// =========================================================================
if (combined.includes('sbb') || combined.includes('cff') || combined.includes('ffs')) {
return buildResult(BRANDS.SBB);
}
if (combined.includes('sncb') || combined.includes('nmbs')) {
return buildResult(BRANDS.SNCB);
}
if (combined.includes('cfl')) {
return buildResult(BRANDS.CFL);
}
// =========================================================================
// 15. TER RÉGIONAUX - DÉTECTION ROBUSTE PAR PALIER
// =========================================================================
// Palier A : Mots-clés régionaux explicites dans l'opérateur ou type
if (combined.includes('lio') || combined.includes('occitanie') || combined.includes('languedoc') || combined.includes('midipyrenees')) {
return buildResult(BRANDS.LIO);
}
if (combined.includes('mobigo') || combined.includes('bourgogne') || combined.includes('franchecomte')) {
return buildResult(BRANDS.MOBIGO);
}
if (combined.includes('zou') || combined.includes('paca') || combined.includes('ler')) {
return buildResult(BRANDS.ZOU);
}
if (combined.includes('fluo') || combined.includes('grandest') || combined.includes('alsace') || combined.includes('lorraine') || combined.includes('metrolor')) {
return buildResult(BRANDS.FLUO);
}
if (combined.includes('aleop') || combined.includes('paysdelaloire')) {
return buildResult(BRANDS.ALEOP);
}
if (combined.includes('breizh') || combined.includes('bretagne')) {
return buildResult(BRANDS.BREIZHGO);
}
if (combined.includes('nomad') || combined.includes('normandie')) {
return buildResult(BRANDS.NOMAD);
}
if (combined.includes('remi') || combined.includes('centrevaldeloire')) {
return buildResult(BRANDS.REMI);
}
if (combined.includes('hautsdefrance') || combined.includes('nordpasdecalais') || combined.includes('picardie')) {
return buildResult(BRANDS.HAUTS_DE_FRANCE);
}
if (combined.includes('auvergerhonealpes') || combined.includes('rhonealpes') || combined.includes('auvergne')) {
return buildResult(BRANDS.AUVERGNE_RHONE_ALPES);
}
if (combined.includes('nouvelleaquitaine') || combined.includes('aquitaine') || combined.includes('poitoucharentes') || combined.includes('limousin')) {
return buildResult(BRANDS.NOUVELLE_AQUITAINE);
}
// Palier B : Plan de numérotation SNCF (Strict & Déterministe)
const regionByNum = getRegionByTrainNumber(numTrain);
if (regionByNum) {
return buildResult(regionByNum);
}
// Palier C : Analyse géographique par score d'arrêts (si train hors numérotation standard)
const regionByRoute = detectRegionByRouteScore(fullRouteText);
if (regionByRoute) {
return buildResult(regionByRoute);
}
// =========================================================================
// 16. TER STANDARD (Fallback)
// =========================================================================
if (combined.includes('ter')) {
return buildResult(BRANDS.TER_GENERIC);
}
// =========================================================================
// 17. SNCF VOYAGEURS (Fallback par défaut)
// =========================================================================
return buildResult(BRANDS.SNCF_GENERIC, {
typeLabel: rawType || 'SNCF',
quaiLabel: rawType || 'SNCF'
});
}
/**
* Interface pour les afficheurs de Quai classiques (ClassiquesDepartsQuais & ClassiquesArriveesQuais)
* @param {Object|string} trainTypeOrHoraire
* @param {string} [operator]
* @param {boolean} [isDeparture=true]
* @param {string} [numTrainOverride]
* @returns {{ logo: string, label: string, showVoyageursText: boolean, isTgv: boolean, classification: Object }}
*/
export function getTrainBrand(trainTypeOrHoraire = '', operator = '', isDeparture = true, numTrainOverride = '') {
const c = classifyTrain(trainTypeOrHoraire, operator, numTrainOverride);
// Pour les trains sans marque spécifique :
if (c.id === 'sncf' || c.id === 'ter') {
if (isDeparture) {
return {
logo: BRANDS.SNCF_GENERIC.quaiLogo,
label: c.quaiLabel || 'SNCF',
showVoyageursText: true,
isTgv: Boolean(c.isTgv),
classification: c
};
}
return {
logo: BRANDS.TER_GENERIC.quaiLogo,
label: c.quaiLabel || 'TER',
showVoyageursText: false,
isTgv: Boolean(c.isTgv),
classification: c
};
}
return {
logo: c.quaiLogo,
label: c.quaiLabel,
showVoyageursText: Boolean(c.showVoyageursText),
isTgv: Boolean(c.isTgv),
classification: c
};
}
/**
* Interface pour les afficheurs Hall classiques (ClassiquesDeparts & ClassiquesArrivees)
* @param {Object} horaire
* @returns {{ logoSrc: string, typeLabel: string, numTrain: string, isTgv: boolean, brand: Object, classification: Object }}
*/
export function getTrainLogoAndMeta(horaire) {
if (!horaire) {
return {
logoSrc: BRANDS.TER_GENERIC.hallLogo,
typeLabel: 'TER',
numTrain: '',
isTgv: false,
brand: getTrainBrand(),
classification: BRANDS.TER_GENERIC
};
}
const c = classifyTrain(horaire);
const brand = getTrainBrand(horaire, '', true);
return {
logoSrc: c.hallLogo,
typeLabel: c.typeLabel,
numTrain: c.numTrain,
isTgv: Boolean(c.isTgv),
brand,
classification: c
};
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Base de données exhaustive des plages de numérotation GTFS SNCF
* Source : GTFS SNCF Open Data (feed valide 02/10/2026 au 31/03/2027)
*
* 1. Cars TER Réguliers (route_type = 3)
* 2. Cars de Substitution Travaux (route_type = 2)
* 3. Trains TER (route_type = 2)
*/
export const REGIONS_META = {
AURA: { code: 'AURA', name: 'Auvergne-Rhône-Alpes', brand: 'TER AURA', logo: 'logo-auvergerhonealpes.svg', color: '#0077c8', cssClass: 'brand-teraura' },
BFC: { code: 'BFC', name: 'Bourgogne-Franche-Comté', brand: 'MOBIGO', logo: 'logo-mobigo.svg', color: '#cfd82c', cssClass: 'brand-mobigo' },
BRE: { code: 'BRE', name: 'Bretagne', brand: 'BREIZHGO', logo: 'logo-breizhgo.svg', color: '#00c6d3', cssClass: 'brand-breizhgo' },
CVL: { code: 'CVL', name: 'Centre-Val de Loire', brand: 'RÉMI', logo: 'logo-remi.svg', color: '#00874e', cssClass: 'brand-remi' },
GE: { code: 'GE', name: 'Grand Est', brand: 'FLUO', logo: 'logo-fluo.svg', color: '#f78bb8', cssClass: 'brand-fluo' },
HDF: { code: 'HDF', name: 'Hauts-de-France', brand: 'TER HDF', logo: 'logo-hautsdefrance.svg', color: '#c9e000', cssClass: 'brand-terhdf' },
NOR: { code: 'NOR', name: 'Normandie', brand: 'NOMAD', logo: 'logo-nomad.svg', color: '#6bb07a', cssClass: 'brand-nomad' },
NA: { code: 'NA', name: 'Nouvelle-Aquitaine', brand: 'TER NA', logo: 'logo-teraquitaine.svg', color: '#ea5b0c', cssClass: 'brand-terna' },
OCC: { code: 'OCC', name: 'Occitanie', brand: 'liO', logo: 'logo-lio.svg', color: '#e3001b', cssClass: 'brand-lio' },
PDL: { code: 'PDL', name: 'Pays de la Loire', brand: 'ALÉOP', logo: 'logo-aleop.svg', color: '#00b0ca', cssClass: 'brand-aleop' },
PACA: { code: 'PACA', name: "Provence-Alpes-Côte d'Azur", brand: 'ZOU !', logo: 'logo-zou.svg', color: '#0055a4', cssClass: 'brand-zou' },
IDF: { code: 'IDF', name: 'Île-de-France', brand: 'TRANSILIEN', logo: 'logo-sncf.svg', color: '#003b80', cssClass: 'brand-idf' }
};
// 1. CARS TER RÉGULIERS (route_type = 3)
export const CARS_REGULIERS_RANGES = [
{ min: 30131, max: 31478, region: 'AURA' },
{ min: 33155, max: 35072, region: 'AURA' },
{ min: 405036, max: 405562, region: 'AURA' },
{ min: 35000, max: 35065, region: 'BFC' },
{ min: 36518, max: 37529, region: 'BFC' },
{ min: 48022, max: 48027, region: 'BRE' },
{ min: 424300, max: 424336, region: 'CVL' },
{ min: 426210, max: 426223, region: 'CVL' },
{ min: 43001, max: 45141, region: 'GE' },
{ min: 68130, max: 68283, region: 'GE' },
{ min: 428429, max: 429499, region: 'GE' },
{ min: 45530, max: 45569, region: 'HDF' },
{ min: 69400, max: 69830, region: 'HDF' },
{ min: 48000, max: 48033, region: 'NOR' },
{ min: 87002, max: 88061, region: 'NOR' },
{ min: 49957, max: 49974, region: 'NA' },
{ min: 70926, max: 70944, region: 'NA' },
{ min: 94000, max: 94032, region: 'OCC' },
{ min: 95201, max: 95477, region: 'OCC' },
{ min: 457156, max: 457268, region: 'OCC' },
{ min: 461900, max: 462402, region: 'OCC' },
{ min: 49981, max: 49986, region: 'PDL' },
{ min: 75000, max: 75039, region: 'PDL' }
];
// 2. CARS DE SUBSTITUTION TRAVAUX (route_type = 2)
export const CARS_SUBSTITUTION_RANGES = [
{ min: 30000, max: 31497, region: 'AURA' },
{ min: 33005, max: 34813, region: 'AURA' },
{ min: 404001, max: 412498, region: 'AURA' },
{ min: 35039, max: 35044, region: 'BFC' },
{ min: 36501, max: 37304, region: 'BFC' },
{ min: 415035, max: 418367, region: 'BFC' },
{ min: 419501, max: 419627, region: 'BFC' },
{ min: 424010, max: 424403, region: 'BFC' },
{ min: 67864, max: 67872, region: 'BRE' },
{ min: 421001, max: 422112, region: 'BRE' },
{ min: 442003, max: 442008, region: 'BRE' },
{ min: 40850, max: 40904, region: 'CVL' },
{ min: 424020, max: 426502, region: 'CVL' },
{ min: 43103, max: 45499, region: 'GE' },
{ min: 427250, max: 435837, region: 'GE' },
{ min: 45500, max: 46576, region: 'HDF' },
{ min: 85564, max: 85661, region: 'HDF' },
{ min: 436010, max: 436191, region: 'HDF' },
{ min: 437480, max: 437845, region: 'HDF' },
{ min: 439005, max: 439537, region: 'HDF' },
{ min: 48001, max: 48271, region: 'NOR' },
{ min: 442108, max: 442129, region: 'NOR' },
{ min: 446802, max: 447248, region: 'NOR' },
{ min: 70452, max: 70769, region: 'NA' },
{ min: 448014, max: 448812, region: 'NA' },
{ min: 450001, max: 451482, region: 'NA' },
{ min: 453009, max: 453872, region: 'NA' },
{ min: 455006, max: 455684, region: 'NA' },
{ min: 60401, max: 60444, region: 'OCC' },
{ min: 73100, max: 73303, region: 'OCC' },
{ min: 95470, max: 95478, region: 'OCC' },
{ min: 412062, max: 412494, region: 'OCC' },
{ min: 455005, max: 455238, region: 'OCC' },
{ min: 457001, max: 457452, region: 'OCC' },
{ min: 459663, max: 459691, region: 'OCC' },
{ min: 461001, max: 462221, region: 'OCC' },
{ min: 464040, max: 464140, region: 'PDL' },
{ min: 64001, max: 64006, region: 'PACA' },
{ min: 79000, max: 79002, region: 'PACA' },
{ min: 466752, max: 468300, region: 'PACA' },
{ min: 445602, max: 445619, region: 'IDF' }
];
// 3. TRAINS TER (route_type = 2)
export const TRAINS_TER_RANGES = [
// 6 CHIFFRES (Segmentation très précise)
{ min: 830102, max: 836407, region: 'GE' },
{ min: 837501, max: 840877, region: 'GE' },
{ min: 841200, max: 844948, region: 'HDF' },
{ min: 846400, max: 849966, region: 'HDF' },
{ min: 848950, max: 848977, region: 'NOR' },
{ min: 850016, max: 853728, region: 'NOR' },
{ min: 854007, max: 858397, region: 'BRE' },
{ min: 857000, max: 860993, region: 'PDL' },
{ min: 860001, max: 863714, region: 'CVL' },
{ min: 862551, max: 862589, region: 'PDL' },
{ min: 863031, max: 869129, region: 'NA' },
{ min: 870000, max: 872891, region: 'OCC' },
{ min: 873901, max: 873994, region: 'OCC' },
{ min: 876100, max: 878991, region: 'OCC' },
{ min: 873003, max: 874231, region: 'AURA' },
{ min: 879102, max: 881699, region: 'PACA' },
{ min: 882103, max: 890392, region: 'AURA' },
{ min: 891000, max: 895975, region: 'BFC' },
// 4 ET 5 CHIFFRES
{ min: 3100, max: 3483, region: 'NOR' },
{ min: 3903, max: 3924, region: 'CVL' },
{ min: 5900, max: 5921, region: 'BFC' },
{ min: 13100, max: 13191, region: 'NOR' },
{ min: 16350, max: 16480, region: 'HDF' },
{ min: 16521, max: 16587, region: 'NOR' },
{ min: 16843, max: 18999, region: 'AURA' },
{ min: 17353, max: 17705, region: 'PACA' },
{ min: 17750, max: 18146, region: 'BFC' },
{ min: 19701, max: 19979, region: 'HDF' },
{ min: 22244, max: 23026, region: 'OCC' },
{ min: 23101, max: 23794, region: 'AURA' },
{ min: 23722, max: 23831, region: 'GE' },
{ min: 33501, max: 33647, region: 'PACA' },
{ min: 81390, max: 81496, region: 'GE' },
{ min: 86002, max: 86104, region: 'PACA' },
{ min: 86380, max: 87470, region: 'GE' },
{ min: 86960, max: 86998, region: 'OCC' },
{ min: 88488, max: 88877, region: 'GE' },
{ min: 96100, max: 96299, region: 'GE' },
{ min: 96500, max: 96769, region: 'AURA' }
];
/**
* Détecte la région probable à partir du nom ou de l'UIC d'une gare
*/
export function guessRegionFromStation(stationNameOrUic) {
if (!stationNameOrUic) return null;
const s = String(stationNameOrUic).toLowerCase();
if (s.includes('dijon') || s.includes('besançon') || s.includes('belfort') || s.includes('chalon') || s.includes('nevers') || s.includes('auxerre') || s.includes('mâcon') || s.includes('sens') || s.includes('bourgogne') || s.includes('franche-comte')) return 'BFC';
if (s.includes('lyon') || s.includes('grenoble') || s.includes('saint-etienne') || s.includes('clermont') || s.includes('chambery') || s.includes('annecy') || s.includes('valence') || s.includes('aura') || s.includes('alpes')) return 'AURA';
if (s.includes('marseille') || s.includes('nice') || s.includes('toulon') || s.includes('avignon') || s.includes('cannes') || s.includes('arles') || s.includes('antibes') || s.includes('paca') || s.includes('azur')) return 'PACA';
if (s.includes('toulouse') || s.includes('montpellier') || s.includes('nimes') || s.includes('perpignan') || s.includes('beziers') || s.includes('narbonne') || s.includes('tarbes') || s.includes('occitanie')) return 'OCC';
if (s.includes('bordeaux') || s.includes('limoges') || s.includes('poitiers') || s.includes('la rochelle') || s.includes('pau') || s.includes('angouleme') || s.includes('bayonne') || s.includes('agen') || s.includes('aquitaine')) return 'NA';
if (s.includes('strasbourg') || s.includes('metz') || s.includes('nancy') || s.includes('reims') || s.includes('mulhouse') || s.includes('colmar') || s.includes('troyes') || s.includes('charleville') || s.includes('alsace') || s.includes('lorraine')) return 'GE';
if (s.includes('lille') || s.includes('amiens') || s.includes('calais') || s.includes('dunkerque') || s.includes('arras') || s.includes('valenciennes') || s.includes('douai') || s.includes('hauts-de-france') || s.includes('nord')) return 'HDF';
if (s.includes('rouen') || s.includes('caen') || s.includes('le havre') || s.includes('cherbourg') || s.includes('evreux') || s.includes('dieppe') || s.includes('alencon') || s.includes('normandie')) return 'NOR';
if (s.includes('rennes') || s.includes('brest') || s.includes('quimper') || s.includes('vannes') || s.includes('saint-brieuc') || s.includes('lorient') || s.includes('saint-malo') || s.includes('bretagne')) return 'BRE';
if (s.includes('nantes') || s.includes('angers') || s.includes('le mans') || s.includes('saint-nazaire') || s.includes('la roche-sur-yon') || s.includes('cholet') || s.includes('laval') || s.includes('loire')) return 'PDL';
if (s.includes('orleans') || s.includes('tours') || s.includes('bourges') || s.includes('blois') || s.includes('chartres') || s.includes('chateauroux') || s.includes('vierzon')) return 'CVL';
if (s.includes('paris') || s.includes('saint-denis') || s.includes('versailles') || s.includes('melun')) return 'IDF';
return null;
}
/**
* Moteur de résolution d'identité de circulation GTFS
* @param {string|number} trainNumStr
* @param {Object} options { stationContext, explicitMode }
*/
export function resolveCirculationIdentity(trainNumStr, options = {}) {
const { stationContext = null, explicitMode = '' } = options;
const num = parseInt(String(trainNumStr || '').replace(/\D/g, ''), 10);
const preferredRegion = guessRegionFromStation(stationContext);
const modeLower = String(explicitMode || '').toLowerCase();
const isExplicitCar = modeLower.includes('car') || modeLower.includes('autocar') || modeLower.includes('bus');
if (!isNaN(num)) {
// 1. Priorité A : Cars de substitution travaux (route_type = 2)
const subMatches = CARS_SUBSTITUTION_RANGES.filter(r => num >= r.min && num <= r.max);
if (subMatches.length > 0) {
const best = (preferredRegion && subMatches.find(m => m.region === preferredRegion)) || subMatches[0];
const meta = REGIONS_META[best.region] || REGIONS_META.AURA;
return {
isCar: true,
isSubstitution: true,
regionCode: best.region,
brand: meta.brand,
typeLabel: 'CAR TER',
logoFile: 'sncf-voyageurs-logo.png',
cssClass: meta.cssClass,
color: meta.color
};
}
// 2. Priorité B : Cars TER Réguliers (route_type = 3)
const regCarMatches = CARS_REGULIERS_RANGES.filter(r => num >= r.min && num <= r.max);
if (regCarMatches.length > 0) {
const best = (preferredRegion && regCarMatches.find(m => m.region === preferredRegion)) || regCarMatches[0];
const meta = REGIONS_META[best.region] || REGIONS_META.AURA;
return {
isCar: true,
isSubstitution: false,
regionCode: best.region,
brand: meta.brand,
typeLabel: 'CAR',
logoFile: 'track-car.svg',
cssClass: meta.cssClass,
color: meta.color
};
}
// Si le numéro est typique des séries autocar (4xxxxx), forcer Car même hors intervalle strict
if (num >= 400000 && num <= 469999) {
return {
isCar: true,
isSubstitution: true,
regionCode: preferredRegion || 'AURA',
brand: preferredRegion && REGIONS_META[preferredRegion] ? REGIONS_META[preferredRegion].brand : 'CAR TER',
typeLabel: 'CAR',
logoFile: 'track-car.svg',
cssClass: preferredRegion && REGIONS_META[preferredRegion] ? REGIONS_META[preferredRegion].cssClass : 'brand-teraura',
color: '#0077c8'
};
}
// 3. Priorité C : Trains TER (route_type = 2)
const trainMatches = TRAINS_TER_RANGES.filter(r => num >= r.min && num <= r.max);
if (trainMatches.length > 0) {
// Si conflit de chevauchement, prioriser la région de la gare si elle correspond
const best = (preferredRegion && trainMatches.find(m => m.region === preferredRegion)) ||
// Sinon favoriser la plage la plus spécifique (écart min/max le plus petit)
trainMatches.sort((a, b) => (a.max - a.min) - (b.max - b.min))[0];
const meta = REGIONS_META[best.region] || REGIONS_META.AURA;
return {
isCar: false,
isSubstitution: false,
regionCode: best.region,
brand: meta.brand,
typeLabel: meta.brand,
logoFile: meta.logo,
cssClass: meta.cssClass,
color: meta.color
};
}
}
// Fallback si rien n'a matché
if (isExplicitCar) {
return {
isCar: true,
isSubstitution: false,
regionCode: preferredRegion || 'SNCF',
brand: 'Car TER',
typeLabel: 'CAR',
logoFile: 'track-car.svg',
cssClass: 'brand-car',
color: '#666666'
};
}
return {
isCar: false,
isSubstitution: false,
regionCode: preferredRegion || 'SNCF',
brand: 'TER',
typeLabel: 'TER',
logoFile: 'logo-ter.svg',
cssClass: 'brand-ter',
color: '#0077c8'
};
}
+183 -113
View File
@@ -1,3 +1,5 @@
import { resolveCirculationIdentity } from './gtfsRegionalRanges';
// Indexation optimisée des logos pour recherche rapide et insensible à la casse / accents // Indexation optimisée des logos pour recherche rapide et insensible à la casse / accents
const normalizeStr = (s) => { const normalizeStr = (s) => {
if (!s) return ''; if (!s) return '';
@@ -11,14 +13,14 @@ const normalizeStr = (s) => {
// Table des logos issue de public/type/data.json // Table des logos issue de public/type/data.json
const LOGOS_DATA = [ const LOGOS_DATA = [
{ file: "logo-aleop.svg", slug: "aleop", name: "Aleop" }, { file: "logo-aleop.svg", slug: "aleop", name: "Aleop" },
{ file: "logo-auvergerhonealpes.svg", slug: "auvergerhonealpes", name: "Auvergerhonealpes" }, { file: "logo-auvergerhonealpes.svg", slug: "auvergerhonealpes", name: "TER AURA" },
{ file: "logo-breizhgo.svg", slug: "breizhgo", name: "Breizhgo" }, { file: "logo-breizhgo.svg", slug: "breizhgo", name: "Breizhgo" },
{ file: "logo-cfl.svg", slug: "cfl", name: "CFL" }, { file: "logo-cfl.svg", slug: "cfl", name: "CFL" },
{ file: "logo-db.svg", slug: "db", name: "DB" }, { file: "logo-db.svg", slug: "db", name: "DB" },
{ file: "logo-eurostar.svg", slug: "eurostar", name: "Eurostar" }, { file: "logo-eurostar.svg", slug: "eurostar", name: "Eurostar" },
{ file: "logo-fluo.svg", slug: "fluo", name: "Fluo" }, { file: "logo-fluo.svg", slug: "fluo", name: "Fluo" },
{ file: "logo-frecciarossa.svg", slug: "frecciarossa", name: "Frecciarossa" }, { file: "logo-frecciarossa.svg", slug: "frecciarossa", name: "Frecciarossa" },
{ file: "logo-hautsdefrance.svg", slug: "hautsdefrance", name: "Hautsdefrance" }, { file: "logo-hautsdefrance.svg", slug: "hautsdefrance", name: "TER HDF" },
{ file: "logo-ice.svg", slug: "ice", name: "ICE" }, { file: "logo-ice.svg", slug: "ice", name: "ICE" },
{ file: "logo-intercites.svg", slug: "intercites", name: "Intercités" }, { file: "logo-intercites.svg", slug: "intercites", name: "Intercités" },
{ file: "logo-ler.svg", slug: "ler", name: "Ler" }, { file: "logo-ler.svg", slug: "ler", name: "Ler" },
@@ -43,21 +45,10 @@ const LOGOS_DATA = [
{ file: "logo-sncf1985.svg", slug: "sncf1985", name: "Sncf1985" }, { file: "logo-sncf1985.svg", slug: "sncf1985", name: "Sncf1985" },
{ file: "logo-sncf1992.svg", slug: "sncf1992", name: "Sncf1992" }, { file: "logo-sncf1992.svg", slug: "sncf1992", name: "Sncf1992" },
{ file: "logo-teoz.svg", slug: "teoz", name: "Teoz" }, { file: "logo-teoz.svg", slug: "teoz", name: "Teoz" },
{ file: "logo-ter.svg", slug: "ter", name: "TER" },
{ file: "logo-ter-rhone-alpes.svg", slug: "ter-rhone-alpes", name: "TER Rhone-Alpes" }, { file: "logo-ter-rhone-alpes.svg", slug: "ter-rhone-alpes", name: "TER Rhone-Alpes" },
{ file: "logo-teralsace.svg", slug: "teralsace", name: "Ter Alsace" },
{ file: "logo-teraquitaine.svg", slug: "teraquitaine", name: "Ter Aquitaine" }, { file: "logo-teraquitaine.svg", slug: "teraquitaine", name: "Ter Aquitaine" },
{ file: "logo-terbassenormandie.svg", slug: "terbassenormandie", name: "Ter Basse Normandie" },
{ file: "logo-terbourgogne.svg", slug: "terbourgogne", name: "Ter Bourgogne" },
{ file: "logo-terbretagne.svg", slug: "terbretagne", name: "Ter Bretagne" },
{ file: "logo-tercentre.svg", slug: "tercentre", name: "Ter Centre" },
{ file: "logo-terlanguedocroussillon.svg", slug: "terlanguedocroussillon", name: "Ter Languedoc-Roussillon" },
{ file: "logo-termidipyrenees.svg", slug: "termidipyrenees", name: "Ter Midi-Pyrenees" },
{ file: "logo-ternordpasdecalais.svg", slug: "ternordpasdecalais", name: "Ter Nord-pas-de-Calais" },
{ file: "logo-terpoitoucharentes.svg", slug: "terpoitoucharentes", name: "Ter Poitou Charentes" },
{ file: "logo-tgv.svg", slug: "tgv", name: "TGV" }, { file: "logo-tgv.svg", slug: "tgv", name: "TGV" },
{ file: "logo-inoui.svg", slug: "inoui", name: "TGV InOui" }, { file: "logo-inoui.svg", slug: "inoui", name: "TGV InOui" },
{ file: "logo-lyria.svg", slug: "lyria", name: "TGV Lyria" },
{ file: "logo-thalys.svg", slug: "thalys", name: "Thalys" }, { file: "logo-thalys.svg", slug: "thalys", name: "Thalys" },
{ file: "logo-thello.svg", slug: "thello", name: "Thello" }, { file: "logo-thello.svg", slug: "thello", name: "Thello" },
{ file: "track-car.svg", slug: "track-car", name: "Car TER" }, { file: "track-car.svg", slug: "track-car", name: "Car TER" },
@@ -81,11 +72,24 @@ LOGOS_DATA.forEach((item) => {
/** /**
* Détermine le logo SVG / PNG et le libellé de train à afficher à partir d'un objet horaire * Détermine le logo SVG / PNG et le libellé de train à afficher à partir d'un objet horaire
* Supporte les données SNCF Open Data (GTFS / SIRI) et Supabase * Intègre la détection déterministe GTFS (Cars réguliers, Cars substitution, Trains régionaux)
* @param {Object} horaire
* @param {string} [stationContext] - Nom de la gare courante pour arbitrage géographique
*/ */
export function getTrainLogoAndMeta(horaire) { export function getTrainLogoAndMeta(horaire, stationContext = null) {
if (!horaire) { if (!horaire) {
return { logoSrc: '/type/logo-sncf.svg', typeLabel: 'TER', numTrain: '' }; return {
logoSrc: '/type/logo-ter.svg',
logoFile: 'logo-ter.svg',
typeLabel: 'TER',
numTrain: '',
isCar: false,
isSubstitution: false,
brand: 'TER',
regionCode: null,
brandClass: 'brand-ter',
brandColor: '#0077c8'
};
} }
const opRaw = String( const opRaw = String(
@@ -108,107 +112,173 @@ export function getTrainLogoAndMeta(horaire) {
const opNorm = normalizeStr(opRaw); const opNorm = normalizeStr(opRaw);
// 1. Recherche directe dans la base des logos // 1. Grands transporteurs nationaux et internationaux prioritaires (Explicites)
let matchedLogo = logoLookup.get(opNorm);
// 2. Recherche par mots-clés si pas de correspondance exacte
if (!matchedLogo) {
if (opNorm.includes('inoui') || opNorm.includes('tgv')) {
matchedLogo = logoLookup.get('inoui') || logoLookup.get('tgv');
} else if (opNorm.includes('ouigoclassique')) {
matchedLogo = logoLookup.get('ouigoclassique');
} else if (opNorm.includes('ouigo')) {
matchedLogo = logoLookup.get('ouigo');
} else if (opNorm.includes('mobigo')) {
matchedLogo = logoLookup.get('mobigo');
} else if (opNorm.includes('fluo')) {
matchedLogo = logoLookup.get('fluo');
} else if (opNorm.includes('remi')) {
matchedLogo = logoLookup.get('remi');
} else if (opNorm.includes('nomad')) {
matchedLogo = logoLookup.get('nomad');
} else if (opNorm.includes('aleop')) {
matchedLogo = logoLookup.get('aleop');
} else if (opNorm.includes('breizhgo')) {
matchedLogo = logoLookup.get('breizhgo');
} else if (opNorm.includes('hautsdefrance')) {
matchedLogo = logoLookup.get('hautsdefrance');
} else if (opNorm.includes('lio')) {
matchedLogo = logoLookup.get('lio');
} else if (opNorm.includes('zou')) {
matchedLogo = logoLookup.get('zou');
} else if (opNorm.includes('intercites') || opNorm.includes('ic')) {
matchedLogo = logoLookup.get('intercites');
} else if (opNorm.includes('ice')) {
matchedLogo = logoLookup.get('ice');
} else if (opNorm.includes('eurostar')) {
matchedLogo = logoLookup.get('eurostar');
} else if (opNorm.includes('thalys')) {
matchedLogo = logoLookup.get('thalys');
} else if (opNorm.includes('lyria')) {
matchedLogo = logoLookup.get('lyria');
} else if (opNorm.includes('frecciarossa') || opNorm.includes('trenitalia')) {
matchedLogo = logoLookup.get('frecciarossa') || logoLookup.get('trenitalia');
} else if (opNorm.includes('renfe') || opNorm.includes('ave')) {
matchedLogo = logoLookup.get('renfeave');
} else if (opNorm.includes('car') || opNorm.includes('autocar') || opNorm.includes('bus')) {
matchedLogo = logoLookup.get('trackcar') || { file: 'track-car.svg', name: 'Car TER' };
}
}
// 3. Détermination du libellé affiché (typeLabel)
let typeLabel = opRaw || 'TER';
if (opNorm.includes('inoui') || opNorm.includes('tgvinoui')) { if (opNorm.includes('inoui') || opNorm.includes('tgvinoui')) {
typeLabel = 'TGV inOUI'; return {
} else if (opNorm === 'tgv') { logoSrc: '/type/logo-inoui.svg',
typeLabel = 'TGV'; logoFile: 'logo-inoui.svg',
} else if (opNorm.includes('ouigoclassique')) { typeLabel: 'TGV inOUI',
typeLabel = 'OUIGO Train Classique'; numTrain,
} else if (opNorm.includes('ouigo')) { isCar: false,
typeLabel = 'OUIGO'; isSubstitution: false,
} else if (opNorm.includes('mobigo')) { brand: 'TGV inOUI',
typeLabel = 'MOBIGO'; regionCode: null,
} else if (opNorm.includes('fluo')) { brandClass: 'brand-inoui',
typeLabel = 'FLUO'; brandColor: '#7a1f3d'
} else if (opNorm.includes('nomad')) { };
typeLabel = 'NOMAD';
} else if (opNorm.includes('remi')) {
typeLabel = 'RÉMI';
} else if (opNorm.includes('aleop')) {
typeLabel = 'ALÉOP';
} else if (opNorm.includes('breizhgo')) {
typeLabel = 'BREIZHGO';
} else if (opNorm.includes('hautsdefrance')) {
typeLabel = 'HAUTS-DE-FRANCE';
} else if (opNorm.includes('lio')) {
typeLabel = 'LIÒ';
} else if (opNorm.includes('zou')) {
typeLabel = 'ZOU !';
} else if (opNorm.includes('intercites') || opNorm === 'ic') {
typeLabel = 'INTERCITÉS';
} else if (opNorm.includes('ice')) {
typeLabel = 'ICE';
} else if (opNorm.includes('eurostar')) {
typeLabel = 'EUROSTAR';
} else if (opNorm.includes('lyria')) {
typeLabel = 'TGV Lyria';
} else if (opNorm.includes('car') || opNorm.includes('autocar') || opNorm.includes('bus')) {
typeLabel = 'AUTOCAR';
} else if (opNorm.includes('ter')) {
typeLabel = 'TER';
} }
// Spécificité régionale Bourgogne-Franche-Comté : TER commençant par 89xxxx ou 84xxxx -> MOBIGO if (opNorm === 'tgv' || (opNorm.includes('tgv') && !opNorm.includes('ouigo') && !opNorm.includes('lyria'))) {
if (typeLabel === 'TER' && (numTrain.startsWith('89') || numTrain.startsWith('84'))) { return {
typeLabel = 'MOBIGO'; logoSrc: '/type/logo-tgv.svg',
if (!matchedLogo || matchedLogo.file === 'logo-ter.svg' || matchedLogo.file === 'logo-sncf.svg') { logoFile: 'logo-tgv.svg',
matchedLogo = logoLookup.get('mobigo') || matchedLogo; typeLabel: 'TGV',
} numTrain,
isCar: false,
isSubstitution: false,
brand: 'TGV',
regionCode: null,
brandClass: 'brand-tgv',
brandColor: '#6e1e24'
};
} }
const logoFile = (matchedLogo && matchedLogo.file) || 'logo-sncf.svg'; if (opNorm.includes('ouigoclassique') || opNorm.includes('ouigotrainclassique')) {
const logoSrc = `/type/${logoFile}`; return {
logoSrc: '/type/logo-ouigo-classique.svg',
logoFile: 'logo-ouigo-classique.svg',
typeLabel: 'OUIGO Classique',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'OUIGO Classique',
regionCode: null,
brandClass: 'brand-ouigo-classique',
brandColor: '#ff007f'
};
}
return { logoSrc, typeLabel, numTrain }; if (opNorm.includes('ouigo')) {
return {
logoSrc: '/type/logo-ouigo.svg',
logoFile: 'logo-ouigo.svg',
typeLabel: 'OUIGO',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'OUIGO',
regionCode: null,
brandClass: 'brand-ouigo',
brandColor: '#00a3e0'
};
}
if (opNorm.includes('intercites') || opNorm === 'ic') {
return {
logoSrc: '/type/logo-intercites.svg',
logoFile: 'logo-intercites.svg',
typeLabel: 'INTERCITÉS',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'INTERCITÉS',
regionCode: null,
brandClass: 'brand-intercites',
brandColor: '#a61c28'
};
}
if (opNorm.includes('eurostar')) {
return {
logoSrc: '/type/logo-eurostar.svg',
logoFile: 'logo-eurostar.svg',
typeLabel: 'EUROSTAR',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'EUROSTAR',
regionCode: null,
brandClass: 'brand-eurostar',
brandColor: '#003865'
};
}
if (opNorm.includes('ice') || opNorm.includes('db')) {
return {
logoSrc: '/type/logo-ice.svg',
logoFile: 'logo-ice.svg',
typeLabel: 'ICE',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'ICE',
regionCode: null,
brandClass: 'brand-ice',
brandColor: '#eb1800'
};
}
if (opNorm.includes('lyria')) {
return {
logoSrc: '/type/logo-lyria.svg',
logoFile: 'logo-lyria.svg',
typeLabel: 'TGV Lyria',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'TGV Lyria',
regionCode: null,
brandClass: 'brand-lyria',
brandColor: '#b4192b'
};
}
if (opNorm.includes('frecciarossa') || opNorm.includes('trenitalia')) {
return {
logoSrc: '/type/logo-frecciarossa.svg',
logoFile: 'logo-frecciarossa.svg',
typeLabel: 'Frecciarossa',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'Trenitalia',
regionCode: null,
brandClass: 'brand-trenitalia',
brandColor: '#c8102e'
};
}
if (opNorm.includes('renfe') || opNorm.includes('ave')) {
return {
logoSrc: '/type/logo-renfeave.svg',
logoFile: 'logo-renfeave.svg',
typeLabel: 'AVE',
numTrain,
isCar: false,
isSubstitution: false,
brand: 'Renfe',
regionCode: null,
brandClass: 'brand-renfe',
brandColor: '#6f2c91'
};
}
// 2. Moteur déterministe GTFS : Déduction par numéro de train et contexte de gare
const identity = resolveCirculationIdentity(numTrain, {
stationContext: stationContext || horaire.gare_depart_nom || horaire.gare_arrivee_nom || '',
explicitMode: opRaw
});
return {
logoSrc: `/type/${identity.logoFile}`,
logoFile: identity.logoFile,
typeLabel: identity.typeLabel,
numTrain,
isCar: identity.isCar,
isSubstitution: identity.isSubstitution,
brand: identity.brand,
regionCode: identity.regionCode,
brandClass: identity.cssClass,
brandColor: identity.color
};
} }
-12
View File
@@ -1,12 +0,0 @@
const horaire_date = "2026-06-14";
const baseDate = new Date(horaire_date);
console.log("baseDate before setHours:", baseDate.toString());
const sched = 330; // 05:30
baseDate.setHours(Math.floor(sched / 60), sched % 60, 0, 0);
console.log("baseDate after setHours:", baseDate.toString());
const currentTime = new Date("2026-06-14T04:49:15+02:00");
console.log("currentTime:", currentTime.toString());
const diffMinutes = (baseDate.getTime() - currentTime.getTime()) / 60000;
console.log("diffMinutes:", diffMinutes);
-17
View File
@@ -1,17 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const gareDisplay = "Ouges";
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
console.log("flexNeedle:", flexNeedle);
const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').ilike('nom', flexNeedle).limit(1);
console.log("gdata:", gdata);
let fetchedType = null;
if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type;
console.log("fetchedType:", fetchedType);
}
run();
-10
View File
@@ -1,10 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data } = await supabase.from('gares').select('id,nom,type').ilike('nom', '%Ouges%');
console.log("Matched gares:", data);
}
run();
-12
View File
@@ -1,12 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data: horaires, error: hErr } = await supabase.from('horaires').select('*').limit(2);
console.log("Horaires sample:", horaires);
const { data: perturbs, error: pErr } = await supabase.from('perturbations').select('*').limit(2);
console.log("Perturbations sample:", perturbs);
}
run();
-23
View File
@@ -1,23 +0,0 @@
const horaires = [{ heure_depart: '21:00', numero_train: '123' }];
const isArrival = false;
const currentTime = new Date();
currentTime.setHours(20, 55, 0, 0);
for (const train of horaires) {
const numTrain = train.num_train || train.numero_train || train.numero;
const timeStr = isArrival
? (train.heure_arrivee || train.arrival_time || train.time || train.heure)
: (train.heure_depart || train.departure_time || train.time || train.heure);
let trainTime;
if (typeof timeStr === 'string' && timeStr.length <= 5 && timeStr.includes(':')) {
const [hh, mm] = timeStr.split(':');
trainTime = new Date(currentTime);
trainTime.setHours(parseInt(hh, 10), parseInt(mm, 10), 0, 0);
} else {
trainTime = new Date(timeStr);
}
const diffMs = trainTime.getTime() - currentTime.getTime();
const diffMins = Math.floor(diffMs / 60000);
console.log(`numTrain=${numTrain}, timeStr=${timeStr}, diffMins=${diffMins}`);
}
-85
View File
@@ -1,85 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
const normalizeName = (s) => {
if (!s) return '';
try {
const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();
return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');
} catch (e) {
return String(s).toLowerCase().trim();
}
};
const parseTimeHM = (v) => {
if (!v) return null;
const m = String(v).match(/(\d{1,2}):(\d{2})/);
if (m) return parseInt(m[1], 10)*60 + parseInt(m[2], 10);
return null;
};
async function run() {
const gareDisplay = "Ouges";
const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`;
const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', flexNeedle).limit(1);
const stationType = gdata && gdata.length ? gdata[0].type.toLowerCase() : null;
console.log("stationType:", stationType);
const thresholdForStationType = (typeGare) => {
const tG = String(typeGare || '').trim().toLowerCase();
if (tG === 'interurbaine' || tG.includes('interurb')) return 360;
if (tG === 'ville' || tG.includes('ville')) return 30;
return 30;
};
const threshold = thresholdForStationType(stationType);
console.log("threshold:", threshold);
const { data: allData } = await supabase.from('horaires').select('*').limit(200);
const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges"));
const h = horaires[0];
const stops = h.gares_desservies;
const targetStationName = gareDisplay;
const stopForStation = (stops || []).find(s => {
const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';
const ns = normalizeName(nm || '');
const target = normalizeName(targetStationName || '');
return (targetStationName && (ns === target || ns.includes(target) || target.includes(ns)));
}) || null;
h._displayPlatform = (stopForStation && stopForStation.quai_reel) || h.quai_reel || '';
console.log("_displayPlatform:", h._displayPlatform);
const platform = (h._displayPlatform || '').toString().trim();
let showPlatform = Boolean(platform);
console.log("Boolean(platform):", showPlatform);
if (showPlatform) {
let diffMinutes = null;
h._depHM = parseTimeHM(h.heure_depart);
const sched = h._depHM;
console.log("sched:", sched);
const currentTime = new Date();
if (sched !== null) {
const baseDate = new Date();
baseDate.setHours(Math.floor(sched / 60), sched % 60, 0, 0);
const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes();
if (sched < 240 && nowHM > 1200) baseDate.setDate(baseDate.getDate() + 1);
else if (sched > 1200 && nowHM < 240) baseDate.setDate(baseDate.getDate() - 1);
diffMinutes = (baseDate.getTime() - currentTime.getTime()) / 60000;
}
console.log("diffMinutes:", diffMinutes);
if (diffMinutes !== null) {
showPlatform = (diffMinutes <= threshold);
} else {
showPlatform = false;
}
}
console.log("FINAL showPlatform:", showPlatform);
}
run();
-10
View File
@@ -1,10 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
const supabaseUrl = 'https://supabase-ferrovia.mr-patator.fr';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1OTUxMjAxLCJleHAiOjE5MzM2MzEyMDF9.ZFuzZSHVOMX-poU1fhWTjZ2sn7LFz9NYATTwJknlSr0';
const supabase = createClient(supabaseUrl, supabaseAnonKey);
async function test() {
const { data: horaires, error } = await supabase.from('horaires').select('*').eq('numero_train', '891807');
console.log(horaires[0]);
}
test();
-16
View File
@@ -1,16 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function test() {
const { data: horaires } = await supabase.from('horaires').select('*').limit(100);
const matched = horaires.filter(h => JSON.stringify(h).includes("Ouges"));
const h = matched[0];
console.log("Train:", h.numero_train);
console.log("Stops:", h.gares_desservies.map(s => ({
gare_nom: s.gare_nom,
quai_reel: s.quai_reel,
gare_id: s.gare_id
})));
}
test();
-10
View File
@@ -1,10 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data } = await supabase.from('perturbations').select('*');
console.log("Perturbations:", data);
}
run();
-10
View File
@@ -1,10 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
const supabaseUrl = 'https://supabase-ferrovia.mr-patator.fr';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1OTUxMjAxLCJleHAiOjE5MzM2MzEyMDF9.ZFuzZSHVOMX-poU1fhWTjZ2sn7LFz9NYATTwJknlSr0';
const supabase = createClient(supabaseUrl, supabaseAnonKey);
async function test() {
const { data: horaires, error } = await supabase.from('horaires_departs').select('*');
console.log('Error:', error);
}
test();
-24
View File
@@ -1,24 +0,0 @@
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
const channel = supabase
.channel('test_channel')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'annonces_diffusions' },
(payload) => {
console.log('Realtime Event Received!', payload);
}
)
.subscribe((status) => {
console.log('Subscription status:', status);
});
console.log('Waiting for events...');
// We will kill this process manually or let it timeout
setTimeout(() => {
console.log('Timeout. Exiting.');
process.exit(0);
}, 10000);
-10
View File
@@ -1,10 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data } = await supabase.from('horaires').select('numero_train, heure_depart, circule_dimanche').ilike('gares_desservies', '%Ouges%');
console.log("Trains Ouges on Sunday:", data);
}
run();
-10
View File
@@ -1,10 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data } = await supabase.from('horaires').select('numero_train, circule_dimanche').in('numero_train', ['891801', '891813']);
console.log("Trains Ouges on Sunday:", data);
}
run();
-11
View File
@@ -1,11 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies, circule_dimanche').limit(200);
const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges") && h.circule_dimanche === true);
console.log("Trains Ouges on Sunday:", horaires.length);
}
run();
-14
View File
@@ -1,14 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, heure_depart, gares_desservies, circule_dimanche').limit(200);
const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges") && h.circule_dimanche === true);
horaires.forEach(h => {
const stop = h.gares_desservies.find(s => s.gare_nom === 'Ouges');
console.log(`Train ${h.numero_train} departs Ouges at ${stop ? stop.heure_depart : h.heure_depart}`);
});
}
run();
-14
View File
@@ -1,14 +0,0 @@
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' });
const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY);
async function run() {
const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, heure_depart, gares_desservies, circule_dimanche').limit(200);
const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges") && h.circule_dimanche === true);
horaires.forEach(h => {
const stop = h.gares_desservies.find(s => s.gare_nom === 'Ouges');
console.log(`Train ${h.numero_train} departs Ouges at ${stop ? stop.heure_depart : h.heure_depart} with quai_reel: ${stop ? stop.quai_reel : 'none'}`);
});
}
run();