From 6dfd798dd2641cfd85de3377ca4d50720e91a014 Mon Sep 17 00:00:00 2001 From: mathis Date: Fri, 28 Aug 2026 04:07:19 +0200 Subject: [PATCH] =?UTF-8?q?RELEASE=20V3.0.0=20:=20Voici=20les=20modificati?= =?UTF-8?q?ons=20apport=C3=A9es=20:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -> Refonte complète des afficheurs AFL départ et arrivée (+ Quais) -> (ALPHA) : Implémentation des afficheurs EVA (Départ et arrivée uniquement pour l'instant). --- .env | 2 +- public/index.html | 23 + src/App.js | 8 + src/components/MarqueeText.js | 12 +- src/pages/afficheurs/afl/AFLArrivees.css | 667 ++++---- src/pages/afficheurs/afl/AFLArrivees.js | 576 ++++--- src/pages/afficheurs/afl/AFLArriveesQuais.css | 504 +++--- src/pages/afficheurs/afl/AFLArriveesQuais.js | 527 ++++--- src/pages/afficheurs/afl/AFLDeparts.css | 677 ++++---- src/pages/afficheurs/afl/AFLDeparts.js | 570 ++++--- src/pages/afficheurs/afl/AFLDepartsQuais.css | 514 +++--- src/pages/afficheurs/afl/AFLDepartsQuais.js | 447 +++--- src/pages/afficheurs/eva/EVAArrivees.css | 476 ++++++ src/pages/afficheurs/eva/EVAArrivees.js | 1355 ++++++++++++++++ src/pages/afficheurs/eva/EVADeparts.css | 476 ++++++ src/pages/afficheurs/eva/EVADeparts.js | 1373 +++++++++++++++++ 16 files changed, 6177 insertions(+), 2030 deletions(-) create mode 100644 src/pages/afficheurs/eva/EVAArrivees.css create mode 100644 src/pages/afficheurs/eva/EVAArrivees.js create mode 100644 src/pages/afficheurs/eva/EVADeparts.css create mode 100644 src/pages/afficheurs/eva/EVADeparts.js diff --git a/.env b/.env index 8cfce24..ac544b1 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ REACT_APP_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr -REACT_APP_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1OTUxMjAxLCJleHAiOjE5MzM2MzEyMDF9.ZFuzZSHVOMX-poU1fhWTjZ2sn7LFz9NYATTwJknlSr0 +REACT_APP_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg3NjY3MjMxLCJleHAiOjE5NDUzNDcyMzF9.uD7YUlOFIhK_Jqbra94o5-N5zdrzKezoCxV067RJi78 diff --git a/public/index.html b/public/index.html index aa069f2..38f329e 100644 --- a/public/index.html +++ b/public/index.html @@ -25,6 +25,29 @@ Learn how to configure a non-root public URL by running `npm run build`. --> React App + diff --git a/src/App.js b/src/App.js index 7a9736f..76199d8 100644 --- a/src/App.js +++ b/src/App.js @@ -14,6 +14,8 @@ import AFLDeparts from './pages/afficheurs/afl/AFLDeparts'; import AFLArrivees from './pages/afficheurs/afl/AFLArrivees'; import AFLDepartsQuais from './pages/afficheurs/afl/AFLDepartsQuais'; import AFLArriveesQuais from './pages/afficheurs/afl/AFLArriveesQuais'; +import EVAArrivees from './pages/afficheurs/eva/EVAArrivees'; +import EVADeparts from './pages/afficheurs/eva/EVADeparts'; import SelectAfficheur from './pages/SelectAfficheur'; applyPolyfills().then(() => defineCustomElements(window)); @@ -141,12 +143,18 @@ function App() { if (route.startsWith('/afficheurs/departs/afl')) { return ; } + if (route.startsWith('/afficheurs/departs/eva')) { + return ; + } if (route.startsWith('/afficheurs/arrivees/afl/quais')) { return ; } if (route.startsWith('/afficheurs/arrivees/afl')) { return ; } + if (route.startsWith('/afficheurs/arrivees/eva')) { + return ; + } if (route.startsWith('/afficheurs/arrivees/classiques')) { return ; } diff --git a/src/components/MarqueeText.js b/src/components/MarqueeText.js index 5f567b4..f911b82 100644 --- a/src/components/MarqueeText.js +++ b/src/components/MarqueeText.js @@ -1,7 +1,7 @@ import React, { useRef, useState, useEffect } from 'react'; import './MarqueeText.css'; -const MarqueeText = ({ text, className }) => { +const MarqueeText = ({ text, className, style }) => { const containerRef = useRef(null); const textRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); @@ -18,13 +18,19 @@ const MarqueeText = ({ text, className }) => { }, [text]); return ( -
+
{text} diff --git a/src/pages/afficheurs/afl/AFLArrivees.css b/src/pages/afficheurs/afl/AFLArrivees.css index b40ed19..2da7cca 100644 --- a/src/pages/afficheurs/afl/AFLArrivees.css +++ b/src/pages/afficheurs/afl/AFLArrivees.css @@ -1,382 +1,479 @@ -/* Déclaration des fontes Achemine (dans public/fonts/) */ -@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; -} -@font-face { - font-family: 'Achemine'; - src: url('/public/fonts/achemine_italic.ttf') format('truetype'); - font-weight: 400; - font-style: italic; - font-display: swap; +/* ========================================================================== + AFL ARRIVEES (GRANDS ARRIVEES SNCF) - CSS + ========================================================================== */ + +/* Reset & Global Container */ +.afl-arr-container { + font-family: 'Achemine', 'Segoe UI', Arial, sans-serif !important; + height: 100vh !important; + width: 100vw !important; + display: flex !important; + flex-direction: column !important; + background-color: #173e31 !important; /* Vert sombre SNCF Arrivées */ + overflow: hidden !important; + color: #ffffff !important; + margin: 0 !important; + padding: 0 !important; + box-sizing: border-box !important; + user-select: none !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; } -.afl-arrivees__container { - font-family: 'Achemine', Arial, 'Helvetica Neue', Helvetica, sans-serif; - height: 100vh; - width: 100vw; - display: flex; - flex-direction: column; - background-color: #1e4a30; /* Vivid dark blue background */ - overflow: hidden; - color: white; - margin: 0; - padding: 0; - box-sizing: border-box; - position: absolute; - top: 0; - left: 0; - z-index: 10000; +/* ========================================================================== + HEADER (10vh) + ========================================================================== */ +.afl-arr-header { + height: 10vh !important; + min-height: 10vh !important; + max-height: 10vh !important; + background-color: #337862 !important; /* Vert moyen SNCF Arrivées */ + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 2.5vw !important; + border-bottom: 2px solid #ffffff !important; + box-sizing: border-box !important; + z-index: 10 !important; } -.afl-arrivees__header { - display: flex; - justify-content: space-between; - align-items: center; - background-color: #216b3e; /* Bright blue header */ - padding: 0 2vw; - height: 10vh; - flex-shrink: 0; +.afl-arr-header-left { + display: flex !important; + align-items: center !important; + gap: 1.5vw !important; } -.afl-arrivees__header-left { - display: flex; - align-items: center; - gap: 1.5vw; - width: 30vw; +.afl-arr-header-title { + font-size: 6.2vh !important; + font-weight: 700 !important; + color: #ffffff !important; + letter-spacing: -0.05vw !important; + line-height: 1 !important; } -.afl-arrivees__title { - font-size: 5vh; - font-weight: 700; +.afl-arr-header-center { + display: flex !important; + align-items: center !important; + gap: 1.2vw !important; } -.afl-arrivees__header-center { - display: flex; - align-items: center; - justify-content: center; - gap: 1vw; - flex: 1; +.afl-arr-page-dot { + width: 4.5vh !important; + height: 4.5vh !important; + border-radius: 50% !important; + background-color: rgba(255, 255, 255, 0.3) !important; + color: #ffffff !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 3.0vh !important; + font-weight: 700 !important; } -.afl-arrivees__page-dot { - width: 4vh; - height: 4vh; - border-radius: 50%; - background-color: #1e4a30; /* Dark blue for inactive */ - color: white; - display: flex; - align-items: center; - justify-content: center; - font-size: 2vh; - font-weight: 700; +.afl-arr-page-dot--active { + background-color: #3dba8b !important; /* Vert clair émeraude actif */ + color: #173e31 !important; } -.afl-arrivees__page-dot--active { - background-color: #4caf50; /* Cyan for active */ +.afl-arr-header-right { + display: flex !important; + align-items: center !important; } -.afl-arrivees__header-right { - display: flex; - justify-content: flex-end; - width: 30vw; +/* Horloge */ +.afl-arr-clock-container { + display: flex !important; + align-items: baseline !important; + font-weight: 700 !important; + color: #ffffff !important; } -.afl-arrivees__header-right .aff-classiques-departs__clock-box { - background: transparent; - border: none; - box-shadow: none; +.afl-arr-clock-main { + font-size: 6.2vh !important; + line-height: 1 !important; + letter-spacing: -0.05vw !important; } -.afl-arrivees__header-right .aff-classiques-departs__clock-main { - font-size: 4.5vh; - font-weight: 600; - color: #ffffff; -} -.afl-arrivees__header-right .aff-classiques-departs__clock-seconds { - display: none; +/* ========================================================================== + TRAIN ROWS LIST + ========================================================================== */ +.afl-arr-list { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + padding: 0 !important; + margin: 0 !important; + list-style: none !important; + background-color: #173e31 !important; } -.afl-arrivees__list { - list-style: none; - margin: 0; - padding: 0; - flex: 1; - display: flex; - flex-direction: column; +.afl-arr-row { + display: flex !important; + width: 100vw !important; + border-bottom: 2px solid #ffffff !important; + padding-left: 2.5vw !important; + padding-right: 1.5vw !important; + background-color: #173e31 !important; + box-sizing: border-box !important; } -.afl-arrivees__row { - display: flex; - align-items: flex-start; - padding: 2.5vh 2vw 0 2vw; - position: relative; - height: 17.5vh; - box-sizing: border-box; +/* -------------------------------------------------------------------------- + MODE ETENDU (20vh) + -------------------------------------------------------------------------- */ +.afl-arr-row--detailed { + height: 20vh !important; + min-height: 20vh !important; + max-height: 20vh !important; + align-items: stretch !important; } -.afl-arrivees__col-time-status { - width: 16vw; - flex-shrink: 0; - display: flex; - flex-direction: column; +.afl-arr-row--detailed .afl-arr-col-time, +.afl-arr-row--detailed .afl-arr-col-train, +.afl-arr-row--detailed .afl-arr-col-dest { + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + height: 100% !important; } -.afl-arrivees__time { - font-size: 7vh; - font-weight: 700; - color: #fce205; /* Bright yellow */ - line-height: 1; - letter-spacing: -2px; +/* -------------------------------------------------------------------------- + MODE REDUIT (10vh) - CENTRAGE PARFAIT DE TOUS LES TEXTES + -------------------------------------------------------------------------- */ +.afl-arr-row--compact { + height: 10vh !important; + min-height: 10vh !important; + max-height: 10vh !important; + align-items: center !important; } -.afl-arrivees__time--suppressed { - color: #ff4444; - font-size: 4vh; - text-transform: uppercase; +.afl-arr-row--compact .afl-arr-col-time, +.afl-arr-row--compact .afl-arr-col-train, +.afl-arr-row--compact .afl-arr-col-dest, +.afl-arr-row--compact .afl-arr-col-voie { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + height: 100% !important; } -.afl-arrivees__status-pill { - display: inline-flex; - align-items: center; - background-color: #216b3e; /* Blue background matching header */ - border-radius: 3vh; - padding: 0.6vh 0.8vw; - margin-top: 1vh; - font-size: 2.5vh; - font-weight: 500; - color: white; - width: max-content; +/* ========================================================================== + COLUMNS LAYOUT + ========================================================================== */ +/* Colonne 1 : Heure & Badge Statut (Centrage synchronisé sur 13.5vw) */ +.afl-arr-col-time { + width: 13.5vw !important; + min-width: 13.5vw !important; + max-width: 13.5vw !important; + margin-right: 2.5vw !important; + padding: 0 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; } -.afl-arrivees__status-pill svg { - margin-right: 0.5vw; +.afl-arr-row--detailed .afl-arr-col-time .afl-arr-top-row { + display: flex !important; + justify-content: center !important; + align-items: flex-end !important; + width: 100% !important; } -.afl-arrivees__status-pill--delayed { - background-color: #ff9e43; - color: #111; +.afl-arr-row--detailed .afl-arr-col-time .afl-arr-bottom-row { + display: flex !important; + justify-content: center !important; + align-items: center !important; + width: 100% !important; } -.afl-arrivees__status-pill--suppressed { - background-color: #ff4c5b; - color: #111; +.afl-arr-row--detailed .afl-arr-col-time .afl-arr-text-time { + text-align: center !important; } -.afl-arrivees__col-train-info { - width: 12vw; - flex-shrink: 0; - display: flex; - flex-direction: column; - padding-top: 0.5vh; - align-items: flex-start; /* Left align type and number */ +.afl-arr-row--compact .afl-arr-col-time { + display: flex !important; + justify-content: center !important; + align-items: center !important; + width: 13.5vw !important; + min-width: 13.5vw !important; + max-width: 13.5vw !important; + margin-right: 2.5vw !important; } -.afl-arrivees__op-name { - font-size: 3vh; - font-weight: 700; - line-height: 1.2; +.afl-arr-row--compact .afl-arr-col-time .afl-arr-text-time { + text-align: center !important; } -.afl-arrivees__op-number { - font-size: 2.5vh; - font-weight: 400; - color: #a4d2b1; - margin-top: 0.5vh; +/* Colonne 2 : Type de train / Opérateur & Numéro */ +.afl-arr-col-train { + width: 15vw !important; + min-width: 15vw !important; + max-width: 15vw !important; + margin-right: 2.5vw !important; + padding: 0 !important; } -.afl-arrivees__col-destination { - flex: 1; - display: flex; - flex-direction: column; - padding-left: 2vw; - padding-right: 2vw; - padding-top: 0; - overflow: hidden; +/* Colonne 3 : Provenance & Gares desservies */ +.afl-arr-col-dest { + flex: 1 !important; + overflow: hidden !important; + padding-right: 1.5vw !important; } -.afl-arrivees__dest-line { - display: flex; - align-items: center; - gap: 1vw; - margin-bottom: 0.5vh; +/* Colonne 4 : Voie */ +.afl-arr-col-voie { + width: 9.5vw !important; + min-width: 9.5vw !important; + max-width: 9.5vw !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; } -.afl-arrivees__destination-name { - font-size: 6vh; - font-weight: 700; - line-height: 1.1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -1px; +/* ========================================================================== + PARTIE HAUTE (LIGNES ETENDUES) + ========================================================================== */ +.afl-arr-top-row { + height: 8.5vh !important; + display: flex !important; + align-items: flex-end !important; + width: 100% !important; + overflow: hidden !important; } -.afl-arrivees__vias-wrapper { - display: flex; - align-items: center; - width: 100%; +/* Heure */ +.afl-arr-text-time { + font-size: 8.0vh !important; + font-weight: 800 !important; + color: #ffff00 !important; + line-height: 1 !important; + letter-spacing: -0.08vw !important; + margin: 0 !important; + padding: 0 !important; } -.afl-arrivees__vias-static { - font-size: 3.5vh; - font-weight: 400; - color: #a4d2b1; - white-space: pre; +.afl-arr-row--compact .afl-arr-text-time { + font-size: 8.0vh !important; + display: flex !important; + align-items: center !important; } -.afl-arrivees__vias-container { - flex: 1; - overflow: hidden; - white-space: nowrap; - position: relative; +/* Opérateur */ +.afl-arr-text-op { + font-size: 5.0vh !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + text-transform: uppercase !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; } -.afl-arrivees__vias { - font-size: 3.5vh; - font-weight: 400; - color: #a4d2b1; - display: inline-block; - white-space: nowrap; - animation: afl-marquee 6.6s linear infinite; - padding-left: 100%; +/* Destination / Provenance */ +.afl-arr-text-dest { + font-size: 8.0vh !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + letter-spacing: -0.05vw !important; + white-space: nowrap !important; } -@keyframes afl-marquee { - 0% { transform: translate(0, 0); } - 100% { transform: translate(-100%, 0); } +.afl-arr-row--compact .afl-arr-text-dest { + font-size: 7.5vh !important; } -.afl-arrivees__col-platform { - width: 12vw; - flex-shrink: 0; - display: flex; - justify-content: flex-end; - padding-top: 0; +/* ========================================================================== + PARTIE BASSE (LIGNES ETENDUES) + ========================================================================== */ +.afl-arr-bottom-row { + height: 7.5vh !important; + display: flex !important; + align-items: center !important; + margin-top: 1.0vh !important; + width: 100% !important; + overflow: hidden !important; } -.afl-arrivees__platform-box { - background-color: white; - border-radius: 0.8vh; - width: 10vw; - height: 12.5vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-shadow: 0 0.5vh 1vh rgba(0,0,0,0.2); +/* Numéro de train */ +.afl-arr-text-num { + font-size: 4.8vh !important; + font-weight: 700 !important; + color: #ffffff !important; + line-height: 1 !important; + letter-spacing: 0.05vw !important; + white-space: nowrap !important; } -.afl-arrivees__platform-label { - color: #1e4a30; /* Matches background */ - font-size: 2.8vh; - font-weight: 700; - line-height: 1.2; +.afl-arr-row--compact .afl-arr-text-num { + font-size: 5.2vh !important; + display: flex !important; + align-items: center !important; } -.afl-arrivees__platform-value { - color: #1e4a30; /* Matches background */ - font-size: 6.8vh; - font-weight: 700; - line-height: 1.1; +/* ========================================================================== + STATUS PILLS (DIMENSIONS EXACTES) + ========================================================================== */ +.afl-arr-pill { + display: inline-flex !important; + align-items: center !important; + justify-content: flex-start !important; + gap: 0.8vw !important; + padding: 0.5vh 1.4vw 0.5vh 0.6vw !important; + border-radius: 3.0vh !important; + height: 5.8vh !important; + margin: 0 !important; } -.afl-arrivees__separator-wrapper { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 2vh; - display: flex; - align-items: center; - justify-content: center; +.afl-arr-pill-text { + font-size: 3.6vh !important; + font-weight: 700 !important; + line-height: 1 !important; + white-space: nowrap !important; } -.afl-arrivees__separator { - position: absolute; - height: 0.4vh; - background-color: #16552a; - width: 96vw; - border-radius: 0.2vh; +.afl-arr-pill--ontime { + background-color: #337862 !important; + color: #ffffff !important; } -.afl-arrivees__train-illustration { - position: absolute; - z-index: 2; - background-color: #1e4a30; /* matches background */ - padding: 0 1vw; +.afl-arr-pill--delayed { + background-color: #f8c898 !important; + color: #111111 !important; } -.afl-arrivees__train-illustration svg { - display: block; +.afl-arr-pill--suppressed { + background-color: #ff5c73 !important; + color: #111111 !important; } -body:has(.afl-arrivees__container) { - margin: 0; - padding: 0; - overflow: hidden; +.afl-arr-icon { + width: 4.0vh !important; + height: 4.0vh !important; + flex-shrink: 0 !important; } -.afl-arrivees__container * { - box-sizing: border-box; +/* ========================================================================== + GARES DESSERVIES (VIAS - 'VIA' FIXE VERT & DEFILEMENT DES GARES ULTRA-RAPIDE) + ========================================================================== */ +.afl-arr-vias-container { + display: flex !important; + align-items: baseline !important; + white-space: nowrap !important; + width: 100% !important; + height: 100% !important; + overflow: hidden !important; } -.afl-arrivees__separator-wrapper { - position: relative; - width: 100%; - height: 10px; - display: flex; - align-items: center; - justify-content: center; +.afl-arr-vias-label { + font-size: 3.6vh !important; + font-weight: 700 !important; + color: #3dba8b !important; /* Vert menthe SNCF Arrivées */ + margin-right: 0.5vw !important; + flex-shrink: 0 !important; + vertical-align: baseline !important; } -.afl-arrivees__separator { - position: absolute; - height: 4px; - background-color: #16552a; - width: calc(100% - 48px); - border-radius: 2px; +.afl-arr-vias-scroll-wrapper { + flex: 1 !important; + overflow: hidden !important; + white-space: nowrap !important; + position: relative !important; + display: flex !important; + align-items: baseline !important; } -.afl-arrivees__train-illustration { - position: absolute; - z-index: 2; - background-color: #01347d; /* match row background so it overlays the line */ - padding: 0 10px; +.afl-arr-vias-inner { + display: inline-block !important; + white-space: nowrap !important; } -.afl-arrivees__row--compact { - height: 10.5vh; - align-items: center; - padding-top: 0; - padding-bottom: 0; +.afl-arr-vias-inner--animating { + animation-name: afl-arr-vias-marquee !important; + animation-timing-function: linear !important; + animation-iteration-count: infinite !important; + padding-left: 20% !important; } -.afl-arrivees__row--compact .afl-arrivees__platform-box { - height: 8vh; +@keyframes afl-arr-vias-marquee { + 0% { + transform: translateX(0%); + } + 100% { + transform: translateX(-100%); + } } -.afl-arrivees__row--compact .afl-arrivees__platform-label { - font-size: 1.8vh; +.afl-arr-vias-stop { + font-size: 5.5vh !important; + font-weight: 400 !important; + color: #ffffff !important; + line-height: 1 !important; + vertical-align: baseline !important; } -.afl-arrivees__row--compact .afl-arrivees__platform-value { - font-size: 4vh; +.afl-arr-vias-chevron { + color: #3dba8b !important; /* Chevrons en vert menthe */ + font-weight: 800 !important; + margin: 0 0.4vw !important; + font-size: 5.5vh !important; + vertical-align: baseline !important; } -.afl-arrivees__row--compact .afl-arrivees__col-time-status, -.afl-arrivees__row--compact .afl-arrivees__col-train-info, -.afl-arrivees__row--compact .afl-arrivees__col-destination, -.afl-arrivees__row--compact .afl-arrivees__col-platform { - padding-top: 0; +/* ========================================================================== + VOIE BOX (BLANCHE / PROPORTIONS STRICTES) + ========================================================================== */ +.afl-arr-voie-box { + background-color: #ffffff !important; + border-radius: 1.8vh !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + gap: 0.2vh !important; + width: 8.5vw !important; + height: 15.2vh !important; + padding: 0.5vh 0 !important; + box-shadow: 0 0.4vh 1.0vh rgba(0, 0, 0, 0.25) !important; } + +.afl-arr-voie-label { + color: #173e31 !important; + font-size: 3.5vh !important; + font-weight: 600 !important; + line-height: 1 !important; + letter-spacing: -0.02vw !important; + margin: 0 !important; +} + +.afl-arr-voie-value { + color: #173e31 !important; + font-size: 9.8vh !important; + font-weight: 800 !important; + line-height: 0.9 !important; + margin: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.afl-arr-voie-box--bus { + justify-content: center !important; + align-items: center !important; + padding: 0 !important; +} + +.afl-arr-voie-box-compact { + background-color: #ffffff !important; + border-radius: 1.0vh !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 6.2vw !important; + height: 7.6vh !important; + padding: 0 !important; + box-shadow: 0 0.2vh 0.5vh rgba(0, 0, 0, 0.25) !important; +} + diff --git a/src/pages/afficheurs/afl/AFLArrivees.js b/src/pages/afficheurs/afl/AFLArrivees.js index a4cf606..e2c65f9 100644 --- a/src/pages/afficheurs/afl/AFLArrivees.js +++ b/src/pages/afficheurs/afl/AFLArrivees.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import './AFLArrivees.css'; import supabase from '../../../lib/supabase'; import { fetchAllRows } from '../../../lib/fetchAllRows'; @@ -25,8 +25,73 @@ const formatTimeNoSeconds = (v) => { return String(v).slice(0,5).replace(':','h'); }; -// (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) +const BusIcon = ({ size = '5.5vh', color = 'white', style = {} }) => ( + + + +); + +const ViasMarquee = ({ viaNames }) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [duration, setDuration] = useState(4); + + useEffect(() => { + const check = () => { + if (containerRef.current && textRef.current) { + const cWidth = containerRef.current.clientWidth; + const tWidth = textRef.current.scrollWidth; + if (tWidth > cWidth) { + setIsOverflowing(true); + // Vitesse de défilement rapide (~280px/s) + setDuration(Math.max(1.0, tWidth / 280)); + } else { + setIsOverflowing(false); + } + } + }; + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, [viaNames]); + + if (!viaNames || viaNames.length === 0) { + return ( +
+ Direct +
+ ); + } + + return ( +
+ via +
+
+ {viaNames.map((v, i) => ( + + {i > 0 && >} + {v} + + ))} +
+
+
+ ); +}; function Clock(){ const [now, setNow] = useState(new Date()); @@ -37,14 +102,10 @@ function Clock(){ 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 ( -
-
-
{hh}:{mm}
-
{ss}
-
+
+ {hh}:{mm}
); } @@ -58,14 +119,17 @@ export default function AFLArrivees(){ // gérer les +, décoder et remplacer les tirets par des espaces const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + // Support pour ?page=1, ?page=2 pour verrouiller l'affichage sur une page spécifique + const pageParam = params.get('page'); + const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10)) + ? Math.max(0, parseInt(pageParam, 10) - 1) + : null; + // debug flag activable via ?debug=1 or ?debug=true const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true'); const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]); - // État pour alterner l'affichage toutes les 3 secondes - // eslint-disable-next-line no-unused-vars - const [showStatus, setShowStatus] = useState(false); // Gardé pour éviter des warnings, mais non utilisé pour basculer - // État pour l'heure actuelle, mise à jour chaque seconde pour la logique d'affichage du quai + // État pour l'heure actuelle const [currentTime, setCurrentTime] = useState(new Date()); // Met à jour l'heure actuelle chaque seconde @@ -76,15 +140,15 @@ export default function AFLArrivees(){ // Données depuis Supabase const [horaires, setHoraires] = useState([]); + // eslint-disable-next-line no-unused-vars const [allHoraires, setAllHoraires] = useState([]); const [perturbations, setPerturbations] = useState([]); - // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...) + // eslint-disable-next-line no-unused-vars const [stationType, setStationType] = useState(null); const [, setTravaux] = useState([]); - const [, setTravauxSubstitutions] = useState([]); + const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); const [, setLoading] = useState(false); const [, setError] = useState(null); - // debug des données brutes renvoyées par Supabase const [, setRawDebug] = useState({ count: 0, sample: [] }); const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); @@ -248,6 +312,7 @@ export default function AFLArrivees(){ }, []); // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant) + // eslint-disable-next-line no-unused-vars const parseTimeHM = useCallback((s) => { if (!s) return null; if (typeof s === 'number') return s; @@ -259,6 +324,7 @@ export default function AFLArrivees(){ }, []); // retourne le threshold (en minutes) pour afficher le quai selon le type de gare + // eslint-disable-next-line no-unused-vars const thresholdForStationType = useCallback((type) => { if (!type) return 30; // 30 minutes par défaut const t = String(type).trim().toLowerCase(); @@ -560,11 +626,6 @@ export default function AFLArrivees(){ }); }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); - useEffect(() => { - const id = setInterval(() => setShowStatus(s => !s), 3000); - return () => clearInterval(id); - }, []); - // Récupération Supabase — maintenant avec polling pour synchroniser sans recharger la page useEffect(() => { let mounted = true; @@ -940,8 +1001,8 @@ export default function AFLArrivees(){ // Debug: log first few annotated horaires try { debug('annotated horaires sample', annotated.slice(0,6).map(a => ({ id: a.id, numero: a.numero_train || a.numero, _status: a._status, _displayTime: a._displayTime }))); } catch (e) {} - // afficher seulement les 9 premières lignes sur l'afficheur - setHoraires((annotated || []).slice(0, 9)); + // Enregistrer tous les horaires (sans limitation à 9 pour alimenter toutes les pages) + setHoraires(annotated || []); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); setTravauxSubstitutions(travauxSubstData || []); @@ -965,14 +1026,14 @@ export default function AFLArrivees(){ // Helpers pour perturbations (memoized plus haut) - const [pageIndex, setPageIndex] = useState(0); - const [showAlternateStatus, setShowAlternateStatus] = useState(false); - + const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0); + const [alternateDelay, setAlternateDelay] = useState(false); + useEffect(() => { - const alternateInterval = setInterval(() => { - setShowAlternateStatus(prev => !prev); + const delayTimer = setInterval(() => { + setAlternateDelay(prev => !prev); }, 2000); - return () => clearInterval(alternateInterval); + return () => clearInterval(delayTimer); }, []); const getPageBounds = useCallback((pIndex) => { @@ -981,9 +1042,13 @@ export default function AFLArrivees(){ return { start, end: start + 8 }; }, []); - const totalPages = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); + const totalPages = Math.max(1, horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8)); useEffect(() => { + if (lockedPageIndex !== null) { + setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); + return; + } const pageInterval = setInterval(() => { setPageIndex((prev) => { const tot = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); @@ -992,241 +1057,258 @@ export default function AFLArrivees(){ }); }, 10000); // Change page every 10 seconds return () => clearInterval(pageInterval); - }, [horaires.length]); + }, [horaires.length, lockedPageIndex, totalPages]); const { start, end } = getPageBounds(pageIndex); const displayedHoraires = horaires.slice(start, end); + const isPage1 = pageIndex === 0; return ( -
- {/* HEADER */} -
-
-
- - - -
-
Arrivées
-
- -
- {totalPages > 1 && Array.from({ length: totalPages }).map((_, i) => ( -
+
+ {/* HEADER */} +
+
+ + {/* Porte de train verticale */} + + {/* Silhouette tête */} + + {/* Silhouette corps + marche vers la droite */} + + {/* Chevrons >> */} + + +
Arrivées
+
+ +
+ {totalPages > 1 && Array.from({ length: totalPages }).map((_, i) => ( +
{i + 1}
))} -
+
-
- -
-
+
+ +
+ - {/* LISTE DES TRAINS */} -
    - {displayedHoraires.length === 0 ? ( -
  • -
    Aucun horaire disponible.
    -
  • - ) : displayedHoraires.map((h, idx) => { - const horaire = h || {}; - const op = (horaire.type_train || horaire.op || '').toUpperCase(); - const number = horaire.numero_train || horaire.number || horaire.numero || ''; - const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue'; - - let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); - const destination = horaire.gare_arrivee_nom || ''; - const platform = (horaire._displayPlatform || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || '').toString().trim(); - - const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); - const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); - const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + {/* ROWS */} +
      + {displayedHoraires.length === 0 ? ( +
    • +
      Aucun horaire disponible.
      +
    • + ) : displayedHoraires.map((h, idx) => { + const horaire = h || {}; + const isDetailed = isPage1 && idx < 4; - // Removed time = '' to stop alternation - - let showPlatform = !(status && status.label === 'supprimé') && Boolean(platform); - if (showPlatform) { - const threshold = thresholdForStationType(stationType); - let diffMinutes = null; - const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_arrivee || horaire.time || horaire.heure || horaire._rawTime || ''); - if (sched !== null) { - let baseDate = new Date(); - if (horaire._date) { - const parts = horaire._date.split('-'); - if (parts.length === 3) { - baseDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)); - } - } - baseDate.setHours(Math.floor(sched / 60), sched % 60, 0, 0); - const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); - if (!horaire._date) { - 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; - } - if (diffMinutes !== null) { - showPlatform = (diffMinutes <= threshold); - } else { - showPlatform = false; - } - } - - // Calcul des vias - const stopsList = getStops(horaire); - const viaNames = []; - if (Array.isArray(stopsList)) { - let isAfterStart = false; - for (const s of stopsList) { - const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; - if (!sName) continue; - const norm = normalizeName(sName); - if (norm === normalizeName(stationName || '')) { - isAfterStart = true; - continue; - } - if (isAfterStart) { - if (norm === normalizeName(destination || '')) { - break; - } - viaNames.push(sName); - } - } - } - const viasStr = viaNames.length > 0 ? viaNames.join(' > ') : ''; - const marqueeStyle = viasStr.length > 0 ? { animationDuration: `${Math.max(6, viasStr.length * 0.25)}s` } : {}; - - const isDelayed = status.label && String(status.label).toLowerCase().includes('retard'); - const isSuppressed = status.label && String(status.label).toLowerCase().includes('supprimé'); + const op = (horaire.type_train || horaire.op || horaire.type || '').toUpperCase(); + const number = horaire.numero_train || horaire.number || horaire.numero || ''; - let displayStatusText = "À l'heure"; - let delayVal = ''; - if (isDelayed) { - displayStatusText = status.label; - if (typeof status.delayMinutes === 'number') { - delayVal = `+${status.delayMinutes} min`; - displayStatusText = delayVal; + const rawPlatform = ( + horaire._displayPlatform || + horaire.quai_reel || + horaire.quai || + horaire.quai_prevu || + horaire.platform || + horaire.voie || + horaire.track || + '' + ).toString().trim(); + + const isCG = rawPlatform.toUpperCase() === 'CG' || + rawPlatform.toUpperCase().includes('CG') || + String(horaire.quai || '').toUpperCase().includes('CG') || + String(horaire.quai_reel || '').toUpperCase().includes('CG') || + String(horaire.quai_prevu || '').toUpperCase().includes('CG') || + String(horaire.platform || '').toUpperCase().includes('CG'); + + const isCarTER = isCG || + op.includes('CAR') || + op.includes('AUTOCAR') || + op.includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('BUS') || + String(horaire.type_train || '').toUpperCase().includes('CAR') || + String(horaire.type_train || '').toUpperCase().includes('BUS') || + String(horaire.libelle_type_train || '').toUpperCase().includes('CAR') || + String(horaire.libelle_type_train || '').toUpperCase().includes('BUS') || + (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero)); + + let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); + const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue'; + + const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); + const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); + const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + + const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé'); + let showPlatform = !isSuppressed && Boolean(rawPlatform) && !isCG; + + const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard'); + const pillClass = isSuppressed ? 'afl-arr-pill--suppressed' : isDelayed ? 'afl-arr-pill--delayed' : 'afl-arr-pill--ontime'; + + let delayVal = '+ 5 min'; + if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { + delayVal = `+ ${status.delayMinutes} min`; + } else if (status?.delayMinutes) { + delayVal = `+ ${status.delayMinutes} min`; + } else if (status?.label && status.label.includes('+')) { + delayVal = status.label; + } + + let delayDisplayText = alternateDelay ? 'retardé' : delayVal; + let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure'; + + // Calcul des vias (gares entre l'origine et la gare d'arrivée) + const stopsList = getStops(horaire); + const viaNames = []; + if (Array.isArray(stopsList)) { + for (const s of stopsList) { + const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; + if (!sName) continue; + const norm = normalizeName(sName); + if (norm === normalizeName(origin || '')) continue; + if (norm === normalizeName(stationName || '')) break; + viaNames.push(sName); } } - if (isSuppressed) displayStatusText = "Supprimé"; - const isBus = op.includes('CAR') || op.includes('AUTOCAR') || op.includes('BUS') || (horaire.mode && horaire.mode.toLowerCase().includes('bus')); + const renderPillIcon = () => { + if (isSuppressed) { + return ( + + + + + ); + } + if (isDelayed) { + return ( + + + + + + ); + } + return ( + + + + + ); + }; - const isCompact = pageIndex > 0 && idx > 0; - const showCompactStatus = isCompact; - - return ( - -
    • -
      - {showCompactStatus && showAlternateStatus ? ( -
      - {isSuppressed ? ( - - - - - ) : isDelayed ? ( - - - - - ) : ( - - - - - )} - {displayStatusText} + return ( +
    • + + {/* TIME & PILL */} +
      + {isDetailed ? ( + <> +
      +
      {time}
      - ) : ( - <> -
      {time}
      - {!isCompact && ( -
      - {isSuppressed ? ( - - - - - ) : isDelayed ? ( - - - - - ) : ( - - - - - )} - {displayStatusText} -
      - )} - - )} -
      - -
      -
      {op}
      - {!isCompact &&
      {number}
      } -
      - -
      -
      - {isBus && ( - - - - )} - -
      - {!isCompact && ( -
      - {viasStr ? ( - <> - via -
      -
      {viasStr}
      -
      - - ) : ( - direct - )} -
      - )} -
      - -
      - {isSuppressed ? ( - - - - - ) : showPlatform ? ( -
      - {!isBus &&
      Voie
      } -
      - {isBus ? ( - - - - ) : platform} +
      +
      + {renderPillIcon()} + {pillText}
      - ) : null} -
      -
    • - {idx < displayedHoraires.length - 1 && ( -
      -
      -
      - )} - - ); - })} -
    -
+ + ) : ( + alternateDelay ? ( +
+ {renderPillIcon()} + {pillText} +
+ ) : ( +
{time}
+ ) + )} +
+ + {/* OP & NUMBER */} +
+ {isDetailed ? ( + <> +
+
{op}
+
+
+
{number}
+
+ + ) : ( + alternateDelay ? ( +
{op || number}
+ ) : ( +
{number || op}
+ ) + )} +
+ + {/* ORIGIN & VIAS */} +
+ {isDetailed ? ( + <> +
+ {isCarTER && } + +
+
+ +
+ + ) : ( +
+ {isCarTER && } + +
+ )} +
+ + {/* VOIE */} +
+ {isDetailed ? ( + isSuppressed ? ( + + + + + ) : isCarTER ? ( +
+ +
+ ) : showPlatform ? ( +
+
Voie
+
{rawPlatform}
+
+ ) : null + ) : ( + isSuppressed ? ( + + + + + ) : isCarTER ? ( +
+ +
+ ) : null + )} +
+ + + ); + })} + +
); } \ No newline at end of file diff --git a/src/pages/afficheurs/afl/AFLArriveesQuais.css b/src/pages/afficheurs/afl/AFLArriveesQuais.css index 7fe0d60..16fde01 100644 --- a/src/pages/afficheurs/afl/AFLArriveesQuais.css +++ b/src/pages/afficheurs/afl/AFLArriveesQuais.css @@ -1,334 +1,280 @@ -/* Déclaration des fontes Achemine (dans public/fonts/) */ -@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; -} -@font-face { - font-family: 'Achemine'; - src: url('/public/fonts/achemine_italic.ttf') format('truetype'); - font-weight: 400; - font-style: italic; - font-display: swap; +/* ========================================================================== + AFL ARRIVEES QUAIS (AFFICHAGE QUAI ARRIVÉE SNCF) - CSS + ========================================================================== */ + +/* Conteneur principal (100vw x 100vh) */ +.afl-q-arr-container { + font-family: 'Achemine', 'Segoe UI', Roboto, sans-serif !important; + background-color: #173e31 !important; /* Vert sombre SNCF Arrivées */ + color: #ffffff !important; + width: 100vw !important; + height: 100vh !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + margin: 0 !important; + padding: 0 !important; + z-index: 9999 !important; + user-select: none !important; } -.afl-quais-arrivees__container { - --bg-color: #1e4a30; /* Deep dark green background */ - --header-bg: #216b3e; /* Lighter vibrant green header */ - --accent-green: #4caf50; /* Green timeline/active color */ - --accent-yellow: #fce205; /* Bright yellow for arrival time */ - --text-grey: #a4d2b1; /* Light green-grey for train info */ +.afl-q-arr-container * { + box-sizing: border-box !important; } -.afl-quais-arrivees__container { - display: flex; - flex-direction: column; - width: 100vw; - height: 100vh; - background-color: var(--bg-color); - color: white; - font-family: 'Achemine', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - overflow: hidden; - position: relative; +/* ========================================================================== + HEADER (10.5vh) : BANDEAU VERT MOYEN + ========================================================================== */ +.afl-q-arr-header { + height: 10.5vh !important; + min-height: 10.5vh !important; + max-height: 10.5vh !important; + background-color: #337862 !important; /* Vert moyen SNCF Arrivées */ + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 2.5vw !important; + border-bottom: 2px solid rgba(255, 255, 255, 0.4) !important; + position: relative !important; + z-index: 10 !important; } -/* --- HEADER --- */ -.afl-quais-arrivees__header { - height: 12vh; - background-color: var(--header-bg); - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 3vw; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - z-index: 10; - box-sizing: border-box; +.afl-q-arr-header-left { + display: flex !important; + align-items: center !important; + gap: 1.2vw !important; } -.afl-quais-arrivees__header-left { - display: flex; - align-items: center; - gap: 1.2vw; +.afl-q-arr-header-icon { + flex-shrink: 0 !important; } -.afl-quais-arrivees__header-title { - font-size: 5vh; - font-weight: 700; - letter-spacing: -0.02em; +.afl-q-arr-header-title { + font-size: 5.6vh !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: -0.03vw !important; + line-height: 1 !important; } -.afl-quais-arrivees__header-clock { - font-size: 5vh; - font-weight: 700; +.afl-q-arr-header-clock { + font-size: 5.8vh !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: -0.04vw !important; + line-height: 1 !important; } -/* --- MAIN CONTENT --- */ -.afl-quais-arrivees__main { - flex: 1; - display: flex; - position: relative; - box-sizing: border-box; +/* ========================================================================== + CORPS PRINCIPAL (MAIN) + ========================================================================== */ +.afl-q-arr-main { + flex: 1 !important; + display: flex !important; + align-items: flex-start !important; /* Hauteur dynamique du panneau gauche */ + padding: 2.5vh 2.5vw 1.5vh 2.5vw !important; + gap: 3.5vw !important; + overflow: hidden !important; + position: relative !important; } -/* --- LEFT PANE (GLUED TO LEFT/TOP, ROUNDED BOTTOM RIGHT) --- */ -.afl-quais-arrivees__left-pane { - position: absolute; - top: 0; - left: 0; - width: 35vw; - height: 38vh; - background: rgba(255, 255, 255, 0.07); - border-radius: 0 0 24px 0; /* Only bottom-right is rounded */ - padding: 4vh 3vw; - display: flex; - flex-direction: column; - box-sizing: border-box; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-right: 1px solid rgba(255, 255, 255, 0.08); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); +/* -------------------------------------------------------------------------- + COLONNE GAUCHE (CARTE DU TRAIN - HAUTEUR DYNAMIQUE) + -------------------------------------------------------------------------- */ +.afl-q-arr-left-card { + width: 29.5vw !important; + min-width: 29.5vw !important; + max-width: 29.5vw !important; + height: auto !important; /* Dynamique selon le nombre d'origines */ + background-color: rgba(23, 62, 49, 0.90) !important; + border: 1.5px solid rgba(255, 255, 255, 0.14) !important; + border-radius: 1.4vh !important; + padding: 1.8vh 1.6vw !important; + display: flex !important; + flex-direction: column !important; + gap: 1.4vh !important; + box-shadow: 0 0.8vh 2.0vh rgba(0, 0, 0, 0.35) !important; + position: relative !important; } -.afl-quais-arrivees__time-status { - display: flex; - align-items: center; - gap: 1vw; - margin-bottom: 1.5vh; +/* En-tête de carte : Heure & Badge Statut */ +.afl-q-arr-card-top { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding-bottom: 1.4vh !important; + border-bottom: 1.5px solid rgba(255, 255, 255, 0.14) !important; } -.afl-quais-arrivees__time { - font-size: 7.5vh; - font-weight: 800; - color: var(--accent-yellow); - line-height: 1; +.afl-q-arr-card-time { + font-size: 5.6vh !important; + font-weight: 800 !important; + color: #ffff00 !important; /* Même couleur jaune que sur AFLArrivees.js */ + line-height: 1 !important; + letter-spacing: -0.04vw !important; } -.afl-quais-arrivees__status-pill { - display: inline-flex; - align-items: center; - background-color: #216b3e; /* Green background matching header */ - border-radius: 3vh; - padding: 0.6vh 0.8vw; - font-size: 2.2vh; - font-weight: 600; - color: white; - width: max-content; +/* Pastille Statut */ +.afl-q-arr-pill { + display: inline-flex !important; + align-items: center !important; + gap: 0.6vw !important; + padding: 0.5vh 1.0vw 0.5vh 0.7vw !important; + border-radius: 2.5vh !important; + background-color: #337862 !important; + font-size: 2.4vh !important; + font-weight: 700 !important; + color: #ffffff !important; + line-height: 1 !important; } -.afl-quais-arrivees__status-pill svg { - margin-right: 0.5vw; +.afl-q-arr-pill--delayed { + background-color: #f8c898 !important; + color: #111111 !important; } -.afl-quais-arrivees__status-pill--delayed { - background-color: #ff9e43; - color: #111; +.afl-q-arr-pill--suppressed { + background-color: #ff5c73 !important; + color: #111111 !important; } -.afl-quais-arrivees__status-pill--suppressed { - background-color: #ff4c5b; - color: #111; +.afl-q-arr-pill-icon { + width: 2.6vh !important; + height: 2.6vh !important; + flex-shrink: 0 !important; } -.afl-quais-arrivees__destination { - font-size: 5.5vh; - font-weight: 700; - line-height: 1.15; - color: #ffffff; - margin-bottom: 1.5vh; - word-wrap: break-word; +/* Provenances & Numéros */ +.afl-q-arr-dest-block { + padding-bottom: 1.4vh !important; + border-bottom: 1.5px solid rgba(255, 255, 255, 0.14) !important; + display: flex !important; + flex-direction: column !important; + gap: 0.6vh !important; } -.afl-quais-arrivees__train-info { - font-size: 3vh; - color: var(--text-grey); - display: flex; - gap: 0.8vw; +.afl-q-arr-dest-title { + font-size: 5.6vh !important; /* Grande police pour la gare de provenance */ + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1.05 !important; + letter-spacing: -0.03vw !important; + word-break: break-word !important; } -.afl-quais-arrivees__train-type { - font-weight: 800; +.afl-q-arr-dest-sub { + font-size: 2.6vh !important; + font-weight: 700 !important; + color: #9be0c8 !important; + letter-spacing: 0.04vw !important; + text-transform: uppercase !important; + line-height: 1 !important; + margin-top: 0.4vh !important; } -.afl-quais-arrivees__train-number { - font-weight: 500; +/* Bas de la carte : Logo du type de train */ +.afl-q-arr-card-footer { + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + padding-top: 0.4vh !important; } -/* --- RIGHT PANE (TIMELINE) --- */ -.afl-quais-arrivees__right-pane { - position: absolute; - top: 4vh; - left: 38vw; - width: 58vw; - bottom: 12vh; - display: flex; - flex-direction: column; - justify-content: center; /* Center stops list vertically */ - box-sizing: border-box; +.afl-q-arr-train-logo-box { + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + max-height: 4.8vh !important; } -.afl-quais-arrivees__stops-window { - width: 100%; - max-height: 100%; - overflow: hidden; - position: relative; +.afl-q-arr-train-logo-img { + max-height: 4.5vh !important; + max-width: 15vw !important; + object-fit: contain !important; } -.afl-quais-arrivees__stops-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - width: 100%; +.afl-q-arr-train-logo-text { + border: 1.5px solid rgba(255, 255, 255, 0.85) !important; + border-radius: 0.6vh !important; + padding: 0.3vh 0.8vw !important; + font-size: 2.5vh !important; + font-style: italic !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: 0.06vw !important; } -.afl-quais-arrivees__stop-item { - display: flex; - align-items: center; - height: 6.2vh; - position: relative; - box-sizing: border-box; +/* -------------------------------------------------------------------------- + COLONNE DROITE (TIMELINE / PLAN DE LIGNE DYNAMIQUE ARRIVEE) + -------------------------------------------------------------------------- */ +.afl-q-arr-timeline-area { + flex: 1 !important; + height: 100% !important; + display: flex !important; + flex-direction: column !important; + justify-content: flex-start !important; + overflow: hidden !important; + position: relative !important; } -/* Timeline Indicator (Line segment + Dot) */ -.afl-quais-arrivees__stop-indicator { - position: relative; - width: 4vw; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; +.afl-q-arr-timeline-svg { + width: 100% !important; + height: 100% !important; + overflow: visible !important; } -.afl-quais-arrivees__stop-line { - position: absolute; - left: 50%; - top: 0; - bottom: 0; - width: 0.6vh; - transform: translateX(-50%); - background-color: var(--accent-green); - box-shadow: 0 0 6px rgba(76, 175, 80, 0.4); - z-index: 1; +/* ========================================================================== + FOOTER : BOX VOIE EN BAS À DROITE (SANS REPÈRES DE QUAI NI COMPOSITION) + ========================================================================== */ +.afl-q-arr-footer { + height: 14vh !important; + min-height: 14vh !important; + max-height: 14vh !important; + display: flex !important; + align-items: flex-end !important; + justify-content: flex-end !important; + padding: 0 2.5vw 1.8vh 2.5vw !important; + position: relative !important; + z-index: 10 !important; } -/* Adjust line ends for first and last stops */ -.afl-quais-arrivees__stop-item.is-first .afl-quais-arrivees__stop-line { - top: 50%; +/* -------------------------------------------------------------------------- + BOX VOIE (BLANCHE / PROPORTIONS STRICTES) + -------------------------------------------------------------------------- */ +.afl-q-arr-voie-box { + background-color: #ffffff !important; + border-radius: 1.2vh !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + width: 7.2vw !important; + min-width: 7.2vw !important; + height: 12.5vh !important; + padding: 0.4vh 0 !important; + box-shadow: 0 0.6vh 1.5vh rgba(0, 0, 0, 0.35) !important; + flex-shrink: 0 !important; } -.afl-quais-arrivees__stop-item.is-last .afl-quais-arrivees__stop-line { - bottom: 50%; +.afl-q-arr-voie-label { + color: #173e31 !important; + font-size: 2.4vh !important; + font-weight: 600 !important; + line-height: 1 !important; + letter-spacing: -0.02vw !important; + margin: 0 !important; } -/* Standard dot */ -.afl-quais-arrivees__stop-dot { - width: 1.4vh; - height: 1.4vh; - border-radius: 50%; - background-color: var(--accent-green); - box-shadow: 0 0 6px rgba(76, 175, 80, 0.6); - z-index: 2; - position: relative; -} - -/* Terminus dot */ -.afl-quais-arrivees__stop-dot.is-terminus { - width: 2.6vh; - height: 2.6vh; - background-color: var(--bg-color); /* Hollow effect */ - border: 0.6vh solid var(--accent-green); - box-shadow: 0 0 8px rgba(76, 175, 80, 0.8); - border-radius: 50%; -} - -/* Stop Names */ -.afl-quais-arrivees__stop-name { - font-size: 3.8vh; - font-weight: 500; - color: #ffffff; - padding-left: 1vw; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.afl-quais-arrivees__stop-name.is-terminus { - font-size: 4.5vh; - font-weight: 700; - color: #ffffff; - padding-left: 1vw; -} - -/* --- BOTTOM TRAIN ILLUSTRATION --- */ -.afl-quais-arrivees__bottom-container { - position: absolute; - bottom: 3.5vh; - left: 38vw; - width: 42vw; - display: flex; - justify-content: center; - align-items: center; - box-sizing: border-box; -} - -.afl-quais-arrivees__train-illustration { - display: flex; - justify-content: center; - opacity: 0.85; -} - -/* --- VOIE BOX (PLATFORM BOX) --- */ -.afl-quais-arrivees__platform-box { - position: absolute; - bottom: 2vh; - right: 2vw; - background-color: #ffffff; - border-radius: 8px; - width: 6.5vw; - height: 13vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.35); - z-index: 10; - box-sizing: border-box; -} - -.afl-quais-arrivees__platform-label { - font-size: 2.2vh; - color: var(--bg-color); - font-weight: 700; - text-transform: capitalize; - line-height: 1; - margin-bottom: 0.5vh; -} - -.afl-quais-arrivees__platform-value { - font-size: 7vh; - color: var(--bg-color); - font-weight: 800; - line-height: 1; -} - -/* --- NO DEPARTURE / LOADING STATE --- */ -.afl-quais-arrivees__no-departure { - display: flex; - justify-content: center; - align-items: center; - height: 88vh; - font-size: 6vh; - font-weight: 600; - color: #a4d2b1; +.afl-q-arr-voie-value { + color: #173e31 !important; + font-size: 6.8vh !important; + font-weight: 800 !important; + line-height: 0.9 !important; + margin: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; } diff --git a/src/pages/afficheurs/afl/AFLArriveesQuais.js b/src/pages/afficheurs/afl/AFLArriveesQuais.js index c0acf34..5973ffe 100644 --- a/src/pages/afficheurs/afl/AFLArriveesQuais.js +++ b/src/pages/afficheurs/afl/AFLArriveesQuais.js @@ -3,6 +3,7 @@ import supabase from '../../../lib/supabase'; import { fetchAllRows } from '../../../lib/fetchAllRows'; import './AFLArriveesQuais.css'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import Logo from '../../../components/Logo'; // Helper utilitaire : format HH:MM sans secondes (ex: 15:57) const formatTimeNoSeconds = (v) => { @@ -25,56 +26,18 @@ const AFLArriveesQuais = () => { const [nextArrival, setNextArrival] = useState(null); const [loading, setLoading] = useState(true); - // Parse URL manually since we are not in a React Router context const searchParams = new URLSearchParams(window.location.search); const gareParam = searchParams.get('gare') || ''; - const gareIdParam = searchParams.get('gare_id') || searchParams.get('gareId'); const quaiParam = searchParams.get('quai') || searchParams.get('voie') || ''; const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; - const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); - useAnnonceSonoreRealtime(resolvedGareId, 'AFLArriveesQuais', quaiParam); + useAnnonceSonoreRealtime(null, 'AFLArriveesQuais', quaiParam); - useEffect(() => { - let mounted = true; - const resolveGareId = async () => { - if (!supabase) return; - try { - if (gareIdParam) { - const { data: gdata } = await supabase.from('gares').select('id').eq('id', gareIdParam).limit(1); - if (mounted && gdata && gdata.length) { - setResolvedGareId(gdata[0].id); - } - } else if (gareDisplay) { - const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; - const { data: gdata } = await supabase.from('gares').select('id, nom').ilike('nom', flexNeedle); - if (gdata && gdata.length) { - const exactMatch = gdata.find(g => { - const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); - const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); - return n1 === n2; - }); - const chosen = exactMatch || gdata[0]; - if (mounted && chosen) { - setResolvedGareId(chosen.id); - } - } - } - } catch (e) { - console.error('[AFLArriveesQuais] Erreur resolution gare:', e); - } - }; - resolveGareId(); - return () => { mounted = false; }; - }, [gareIdParam, gareDisplay]); - - // Real-time clock update useEffect(() => { const timer = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(timer); }, []); - // --- Helpers pour perturbations, travaux, etc --- const normalizeName = useCallback((s) => { if (!s) return ''; try { @@ -85,20 +48,6 @@ const AFLArriveesQuais = () => { } }, []); - const safeParseArrets = useCallback((p) => { - const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); - if (!raw) return []; - if (Array.isArray(raw)) return raw; - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - if (Array.isArray(parsed.arrets)) return parsed.arrets; - return parsed; - } - return []; - } catch (e) { return []; } - }, []); - const getStops = useCallback((h) => { if (!h) return []; if (Array.isArray(h.stops) && h.stops.length) return h.stops; @@ -116,6 +65,20 @@ const AFLArriveesQuais = () => { return []; }, []); + const safeParseArrets = useCallback((p) => { + const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); + if (!raw) return []; + if (Array.isArray(raw)) return raw; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + if (Array.isArray(parsed.arrets)) return parsed.arrets; + return parsed; + } + return []; + } catch (e) { return []; } + }, []); + const parsePerturbArretsObject = useCallback((p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return { arrets: [], jours: null }; @@ -157,18 +120,8 @@ const AFLArriveesQuais = () => { else pushDate(candidate.date || null); } } - try { - const parsedObj = parsePerturbArretsObject(p); - const jp = parsedObj.jours; - if (jp) { - if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); - else if (typeof jp === 'string') { - try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } - } else if (typeof jp === 'object') pushDate(jp.date || null); - } - } catch (e) {} return result.size ? result : null; - }, [parsePerturbArretsObject]); + }, []); const parseDelayMinutesFromArret = useCallback((arret) => { if (!arret) return null; @@ -192,8 +145,6 @@ const AFLArriveesQuais = () => { return null; }, []); - - const buildPerturbMap = useCallback((list, allHorairesList) => { const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] }; (list || []).forEach(p => { @@ -212,28 +163,9 @@ const AFLArriveesQuais = () => { } if (horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length)) { idx.global.push(p); } }); - if (Array.isArray(allHorairesList) && allHorairesList.length) { - try { - const byH = Object.keys(idx.byHoraire || {}); - if (byH && byH.length) { - for (const hid of byH) { - const h = allHorairesList.find(x => String(x.id) === String(hid)); - if (h) { - const num = h.numero_train || h.number || h.numero || null; - if (num != null) { - const numKey = String(num); - idx.byNumber[numKey] = idx.byNumber[numKey] || []; - (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); - } - } - } - } - } catch (e) {} - } return idx; }, [safeParseArrets, normalizeName]); - const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { const map = idx || buildPerturbMap(); const allPerturbs = []; @@ -691,192 +623,293 @@ const AFLArriveesQuais = () => { return () => clearInterval(interval); }, [fetchData]); - if (loading) return
; - if (!nextArrival) return ( -
-
-
- - - -
Prochaine arrivée
-
-
{formatTimeNoSeconds(currentTime)}
+ if (loading) return
; + + if (!nextArrival) { + return ( +
+
+
+ + + + + + +
Prochaine arrivée
+
+
{formatTimeNoSeconds(currentTime)}
+
+
+ Aucune arrivée prévue
-
Aucune arrivée prévue
-
- ); +
+ {quaiParam && ( +
+
Voie
+
{quaiParam}
+
+ )} +
+
+ ); + } - const stops = getStops(nextArrival); - let filteredStops = []; + const trainData = nextArrival; + + const isSuppressed = trainData._status && String(trainData._status.label || '').toLowerCase().includes('supprimé'); + const isDelayed = trainData._status && String(trainData._status.label || '').toLowerCase().includes('retard'); - if (stops.length > 0 && gareDisplay) { - const stationIndex = stops.findIndex(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); - if (stationIndex !== -1) { - filteredStops = stops.slice(0, stationIndex); - } else { - filteredStops = stops.slice(); - } - } else { - filteredStops = stops.slice(); + let delayVal = '+ 5 min'; + if (typeof trainData._status?.delayMinutes === 'number' && trainData._status.delayMinutes > 0) { + delayVal = `+ ${trainData._status.delayMinutes} min`; + } else if (trainData._status?.label && trainData._status.label.includes('+')) { + delayVal = trainData._status.label; } - const originName = nextArrival.gare_depart_nom || nextArrival.gare_depart || 'Origine'; - if (filteredStops.length > 0) { - const firstStopName = normalizeName((filteredStops[0].gare_nom || filteredStops[0].name || filteredStops[0].nom || '').toString()); - const normOrigin = normalizeName(originName); - if (firstStopName !== normOrigin && !firstStopName.includes(normOrigin) && !normOrigin.includes(firstStopName)) { - filteredStops.unshift({ gare_nom: originName, isFirst: true }); - } else { - filteredStops[0].isFirst = true; - } - } else { - filteredStops.unshift({ gare_nom: originName, isFirst: true }); - } + const pillClass = isSuppressed ? 'afl-q-arr-pill--suppressed' : isDelayed ? 'afl-q-arr-pill--delayed' : ''; + const pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayVal : "À l'heure"; - const targetStationName = gareDisplay || 'Terminus'; - filteredStops.push({ gare_nom: targetStationName, isLast: true }); - - // Deduplicate stops by name - const seenStops = new Set(); - filteredStops = filteredStops.filter(s => { - const name = normalizeName((s.gare_nom || s.name || s.nom || s.stop_name || '').toString()); - if (!name) return false; - if (seenStops.has(name)) return false; - seenStops.add(name); - return true; - }); - - const isSuppressed = nextArrival._status && nextArrival._status.label === 'supprimé'; - const isDelayed = nextArrival._status && nextArrival._status.label && String(nextArrival._status.label).toLowerCase().includes('retard'); - let displayStatusText = "À l'heure"; - if (isSuppressed) displayStatusText = "Supprimé"; - else if (isDelayed) { - if (typeof nextArrival._status.delayMinutes === 'number') { - displayStatusText = `+${nextArrival._status.delayMinutes} min`; - } else { - displayStatusText = nextArrival._status.label; + const renderPillIcon = () => { + if (isSuppressed) { + return ( + + + + + ); } - } + if (isDelayed) { + return ( + + + + + + ); + } + return ( + + + + + ); + }; - const displayTime = nextArrival._displayTime || formatTimeNoSeconds(nextArrival.heure_arrivee || nextArrival.time || nextArrival.heure || ''); - const trainType = nextArrival.type_train || nextArrival.op || 'TER'; - const trainNum = nextArrival.numero_train || nextArrival.numero || nextArrival.number || ''; - - const stopsList = getStops(nextArrival); + const displayTime = trainData._displayTime || formatTimeNoSeconds(trainData.heure_arrivee || trainData.time || trainData.heure || ''); + const trainType = trainData.type_train || trainData.op || 'TER'; + const trainNum = trainData.numero_train || trainData.numero || trainData.number || ''; + const originName = trainData.gare_depart_nom || trainData.gare_depart || 'Origine'; + + const stopsList = getStops(trainData); let stopForStation = null; if (gareDisplay && Array.isArray(stopsList)) { stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); } - const platformStr = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || nextArrival.quai_reel || nextArrival.voie || nextArrival.quai || quaiParam || '').toString().trim(); + const platformStr = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || trainData.quai_reel || trainData.voie || trainData.quai || quaiParam || '').toString().trim(); - const shouldScroll = filteredStops.length > 10; - // eslint-disable-next-line no-unused-vars - const scrollStyle = shouldScroll ? { - animation: `scrollStops ${filteredStops.length * 2.2}s linear infinite` - } : {}; + // Extraction des origines (si multi-origines / tranches) + let origins = []; + if (Array.isArray(trainData.tranches) && trainData.tranches.length > 0) { + origins = trainData.tranches.map(t => ({ + name: t.gare_depart_nom || t.origine || t.nom, + op: t.type_train || t.op || trainType, + number: t.numero_train || t.number || trainNum + })); + } else if (originName.includes('/')) { + origins = originName.split('/').map(d => ({ + name: d.trim(), + op: trainType, + number: trainNum + })); + } else { + origins = [{ + name: originName, + op: trainType, + number: trainNum + }]; + } + + // Formatage des lignes d'origines (découpage élégant sur 2 lignes si nécessaire) + const formatDestLines = (name) => { + if (!name) return ['Origine']; + const clean = name.trim(); + if (clean.includes('Marseille Saint-Charles')) return ['Marseille Saint-', 'Charles']; + if (clean.includes('Montpellier Saint-Roch')) return ['Montpellier', 'Saint-Roch']; + if (clean.includes('Paris Gare de Lyon')) return ['Paris', 'Gare de Lyon']; + if (clean.includes('Paris Montparnasse')) return ['Paris', 'Montparnasse']; + if (clean.includes('Bourg-en-Bresse')) return ['Bourg-en-', 'Bresse']; + if (clean.length > 12) { + const parts = clean.split(' '); + if (parts.length >= 2) { + const mid = Math.ceil(parts.length / 2); + return [parts.slice(0, mid).join(' '), parts.slice(mid).join(' ')]; + } + } + return [clean]; + }; + + // Calcul dynamique des arrêts desservis en amont (depuis l'origine jusqu'à la gare actuelle) + const currentStationName = gareDisplay || (stopForStation && (stopForStation.gare_nom || stopForStation.nom || stopForStation.name)) || (stopsList[stopsList.length - 1] && (stopsList[stopsList.length - 1].gare_nom || stopsList[stopsList.length - 1].nom || stopsList[stopsList.length - 1].name)) || 'Terminus'; + + let journeyStops = []; + if (Array.isArray(stopsList) && stopsList.length > 0) { + const normCurrent = normalizeName(currentStationName); + const endIdx = stopsList.findIndex(s => { + const sName = normalizeName((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)); + }); + + const rawStops = endIdx !== -1 ? stopsList.slice(0, endIdx + 1) : stopsList.slice(); + const seen = new Set(); + rawStops.forEach(s => { + 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); + if (name && !seen.has(norm)) { + seen.add(norm); + journeyStops.push(name); + } + }); + } + + // Si aucun arrêt n'est trouvé, afficher l'origine et le terminus actuel + if (journeyStops.length === 0) { + journeyStops = [originName, currentStationName]; + } else if (journeyStops.length === 1 && journeyStops[0] !== currentStationName) { + journeyStops.push(currentStationName); + } + + const startStopName = journeyStops[0]; + const subsequentStops = journeyStops.slice(1); return ( -
- {/* Header */} -
-
- - - -
Prochaine arrivée
+
+ {/* HEADER (10.5vh) */} +
+
+ + + + + + +
Prochaine arrivée
-
{formatTimeNoSeconds(currentTime)}
-
+
{formatTimeNoSeconds(currentTime)}
+ -
- {/* Left Pane (Floating Card) */} -
-
-
{displayTime}
-
- {isSuppressed ? ( - - - - - ) : isDelayed ? ( - - - - - ) : ( - - - - - )} - {displayStatusText} + {/* CORPS PRINCIPAL */} +
+ {/* PANNEAU GAUCHE : CARTE D'INFORMATION DU TRAIN */} +
+ {/* Ligne Heure d'arrivée & Statut */} +
+
{displayTime}
+
+ {renderPillIcon()} + {pillText}
-
{originName}
-
- {trainType} - {trainNum} -
-
- {/* Right Pane (Timeline) */} -
-
- -
    - {/* Main List */} - {filteredStops.map((s, i) => { - const sname = s.gare_nom || s.nom || s.name || s.stop_name || ''; - const isFirst = i === 0; - const isLastItem = s.isLast || i === filteredStops.length - 1; - + {/* Blocs Origines dynamiques selon l'horaire */} + {origins.map((d, i) => { + const lines = formatDestLines(d.name); + return ( +
    + {lines.map((line, lIdx) => ( +
    {line}
    + ))} +
    + {d.op} {d.number} +
    +
    + ); + })} + + {/* Pied de carte : Logo du type de train */} +
    + +
    + + + {/* PANNEAU DROIT : TIMELINE ARBORESCENTE DYNAMIQUE DE L'ARRIVEE */} +
    + + {/* 3 mini-points vert émeraude au sommet */} + + + + + {/* Station 1: Gare d'origine */} + + + + {startStopName} + + + {/* Tracé dynamique des arrêts vers le terminus actuel */} + {subsequentStops.length <= 1 ? ( + + + + + {subsequentStops[0] || currentStationName} + + + ) : ( + (() => { + const N = subsequentStops.length; + const yStart = 160; + const yEnd = 500; + const step = Math.min(65, (yEnd - yStart) / Math.max(1, N - 1)); + const lineEndY = yStart + (N - 1) * step; + return ( -
  • -
    -
    -
    -
    -
    {sname}
    -
  • - ); - })} -
-
-
-
+ + + {subsequentStops.map((stopName, sIdx) => { + const isLast = sIdx === N - 1; + const posY = yStart + sIdx * step; - {/* Symmetrical train outline under the stops list */} -
-
- - - - - - - - - + if (!isLast) { + return ( + + + + {stopName} + + + ); + } + return ( + + + + {stopName} + + + ); + })} + + ); + })() + )} + + + + {/* FOOTER : BOX VOIE EN BAS À DROITE */} +
+
+
Voie
+
{platformStr}
-
- - {platformStr && ( -
-
Voie
-
{platformStr}
-
- )} +
); }; diff --git a/src/pages/afficheurs/afl/AFLDeparts.css b/src/pages/afficheurs/afl/AFLDeparts.css index 7c51947..aebdbf0 100644 --- a/src/pages/afficheurs/afl/AFLDeparts.css +++ b/src/pages/afficheurs/afl/AFLDeparts.css @@ -1,382 +1,489 @@ -/* Déclaration des fontes Achemine (dans public/fonts/) */ -@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; -} -@font-face { - font-family: 'Achemine'; - src: url('/public/fonts/achemine_italic.ttf') format('truetype'); - font-weight: 400; - font-style: italic; - font-display: swap; +/* AFL DEPARTS - DESIGN STRICT CONFORME AUX MAQUETTES */ + +.afl-container { + font-family: 'Achemine', 'Segoe UI', Roboto, sans-serif !important; + background-color: #002d62 !important; + color: #ffffff !important; + width: 100vw !important; + height: 100vh !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + margin: 0 !important; + padding: 0 !important; + z-index: 9999 !important; + user-select: none !important; } -.afl-departs__container { - font-family: 'Achemine', Arial, 'Helvetica Neue', Helvetica, sans-serif; - height: 100vh; - width: 100vw; - display: flex; - flex-direction: column; - background-color: #012a6a; /* Vivid dark blue background */ - overflow: hidden; - color: white; - margin: 0; - padding: 0; - box-sizing: border-box; - position: absolute; - top: 0; - left: 0; - z-index: 10000; +.afl-container * { + box-sizing: border-box !important; } -.afl-departs__header { - display: flex; - justify-content: space-between; - align-items: center; - background-color: #005eb8; /* Bright blue header */ - padding: 0 2vw; - height: 10vh; - flex-shrink: 0; +/* ========================================================================== + HEADER (10vh) + ========================================================================== */ +.afl-header { + height: 10vh !important; + min-height: 10vh !important; + max-height: 10vh !important; + background-color: #005fa9 !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 2.5vw !important; + border-bottom: 2px solid #ffffff !important; } -.afl-departs__header-left { - display: flex; - align-items: center; - gap: 1.5vw; - width: 30vw; +.afl-header-left { + display: flex !important; + align-items: center !important; + gap: 1.2vw !important; } -.afl-departs__title { - font-size: 5vh; - font-weight: 700; +.afl-header-title { + font-size: 5.5vh !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: 0.05vw !important; + line-height: 1 !important; } -.afl-departs__header-center { - display: flex; - align-items: center; - justify-content: center; - gap: 1vw; - flex: 1; +.afl-header-center { + display: flex !important; + align-items: center !important; + gap: 0.8vw !important; } -.afl-departs__page-dot { - width: 4vh; - height: 4vh; - border-radius: 50%; - background-color: #012a6a; /* Dark blue for inactive */ - color: white; - display: flex; - align-items: center; - justify-content: center; - font-size: 2vh; - font-weight: 700; +.afl-page-dot { + width: 3.5vh !important; + height: 3.5vh !important; + border-radius: 50% !important; + background: rgba(255, 255, 255, 0.3) !important; + color: #ffffff !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 2.0vh !important; + font-weight: 700 !important; } -.afl-departs__page-dot--active { - background-color: #00c3ff; /* Cyan for active */ +.afl-page-dot--active { + background: #ffffff !important; + color: #005fa9 !important; } -.afl-departs__header-right { - display: flex; - justify-content: flex-end; - width: 30vw; +.afl-header-right { + display: flex !important; + align-items: center !important; + justify-content: flex-end !important; } -.afl-departs__header-right .aff-classiques-departs__clock-box { - background: transparent; - border: none; - box-shadow: none; +/* CLOCK */ +.afl-clock-container { + display: flex !important; + align-items: baseline !important; + color: #ffffff !important; } -.afl-departs__header-right .aff-classiques-departs__clock-main { - font-size: 4.5vh; - font-weight: 600; - color: #ffffff; -} -.afl-departs__header-right .aff-classiques-departs__clock-seconds { - display: none; +.afl-clock-main { + font-size: 6.0vh !important; + font-weight: 800 !important; + line-height: 1 !important; + letter-spacing: -0.05vw !important; } -.afl-departs__list { - list-style: none; - margin: 0; - padding: 0; - flex: 1; - display: flex; - flex-direction: column; +.afl-clock-seconds { + font-size: 3.5vh !important; + font-weight: 700 !important; + margin-left: 0.3vw !important; + opacity: 0.85 !important; + line-height: 1 !important; } -.afl-departs__row { - display: flex; - align-items: flex-start; - padding: 2.5vh 2vw 0 2vw; - position: relative; - height: 17.5vh; - box-sizing: border-box; +/* ========================================================================== + TRAIN ROWS LIST + ========================================================================== */ +.afl-list { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + padding: 0 !important; + margin: 0 !important; + list-style: none !important; + background-color: #002d62 !important; } -.afl-departs__col-time-status { - width: 16vw; - flex-shrink: 0; - display: flex; - flex-direction: column; +.afl-row { + display: flex !important; + width: 100vw !important; + border-bottom: 2px solid #ffffff !important; + padding-left: 2.5vw !important; + padding-right: 1.5vw !important; + background-color: #002d62 !important; } -.afl-departs__time { - font-size: 7vh; - font-weight: 700; - color: #fce205; /* Bright yellow */ - line-height: 1; - letter-spacing: -2px; +/* -------------------------------------------------------------------------- + MODE ETENDU (20vh) + -------------------------------------------------------------------------- */ +.afl-row--detailed { + height: 20vh !important; + min-height: 20vh !important; + max-height: 20vh !important; + align-items: stretch !important; } -.afl-departs__time--suppressed { - color: #ff4444; - font-size: 4vh; - text-transform: uppercase; +.afl-row--detailed .afl-col-time, +.afl-row--detailed .afl-col-train, +.afl-row--detailed .afl-col-dest { + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + height: 100% !important; } -.afl-departs__status-pill { - display: inline-flex; - align-items: center; - background-color: #005eb8; /* Blue background matching header */ - border-radius: 3vh; - padding: 0.6vh 0.8vw; - margin-top: 1vh; - font-size: 2.5vh; - font-weight: 500; - color: white; - width: max-content; +/* -------------------------------------------------------------------------- + MODE REDUIT (10vh) - CENTRAGE PARFAIT DE TOUS LES TEXTES + -------------------------------------------------------------------------- */ +.afl-row--compact { + height: 10vh !important; + min-height: 10vh !important; + max-height: 10vh !important; + align-items: center !important; } -.afl-departs__status-pill svg { - margin-right: 0.5vw; +.afl-row--compact .afl-col-time, +.afl-row--compact .afl-col-train, +.afl-row--compact .afl-col-dest, +.afl-row--compact .afl-col-voie { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + height: 100% !important; } -.afl-departs__status-pill--delayed { - background-color: #ff9e43; - color: #111; +/* Colonne 1 : Heure & Badge Statut (Centrage synchronisé sur 13.5vw) */ +.afl-col-time { + width: 13.5vw !important; + min-width: 13.5vw !important; + max-width: 13.5vw !important; + margin-right: 2.5vw !important; + padding: 0 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; } -.afl-departs__status-pill--suppressed { - background-color: #ff4c5b; - color: #111; +.afl-row--detailed .afl-col-time .afl-top-row { + display: flex !important; + justify-content: center !important; + align-items: flex-end !important; + width: 100% !important; } -.afl-departs__col-train-info { - width: 12vw; - flex-shrink: 0; - display: flex; - flex-direction: column; - padding-top: 0.5vh; - align-items: flex-start; /* Left align type and number */ +.afl-row--detailed .afl-col-time .afl-bottom-row { + display: flex !important; + justify-content: center !important; + align-items: center !important; + width: 100% !important; } -.afl-departs__op-name { - font-size: 3vh; - font-weight: 700; - line-height: 1.2; +.afl-row--detailed .afl-col-time .afl-text-time { + text-align: center !important; } -.afl-departs__op-number { - font-size: 2.5vh; - font-weight: 400; - color: #cce0ff; - margin-top: 0.5vh; +.afl-row--compact .afl-col-time { + display: flex !important; + justify-content: center !important; + align-items: center !important; + width: 13.5vw !important; + min-width: 13.5vw !important; + max-width: 13.5vw !important; + margin-right: 2.5vw !important; } -.afl-departs__col-destination { - flex: 1; - display: flex; - flex-direction: column; - padding-left: 2vw; - padding-right: 2vw; - padding-top: 0; - overflow: hidden; +.afl-row--compact .afl-col-time .afl-text-time { + text-align: center !important; } -.afl-departs__dest-line { - display: flex; - align-items: center; - gap: 1vw; - margin-bottom: 0.5vh; +/* Colonne 2 : Type de train / Opérateur & Numéro */ +.afl-col-train { + width: 15vw !important; + min-width: 15vw !important; + max-width: 15vw !important; + margin-right: 2.5vw !important; + padding: 0 !important; } -.afl-departs__destination-name { - font-size: 6vh; - font-weight: 700; - line-height: 1.1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -1px; +/* Colonne 3 : Destination & Gares desservies */ +.afl-col-dest { + flex: 1 !important; + overflow: hidden !important; + padding-right: 1.5vw !important; } -.afl-departs__vias-wrapper { - display: flex; - align-items: center; - width: 100%; +/* Colonne 4 : Voie */ +.afl-col-voie { + width: 9.5vw !important; + min-width: 9.5vw !important; + max-width: 9.5vw !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; } -.afl-departs__vias-static { - font-size: 3.5vh; - font-weight: 400; - color: #cce0ff; - white-space: pre; +/* ========================================================================== + PARTIE HAUTE (LIGNES ETENDUES) + ========================================================================== */ +.afl-top-row { + height: 8.5vh !important; + display: flex !important; + align-items: flex-end !important; + width: 100% !important; + overflow: hidden !important; } -.afl-departs__vias-container { - flex: 1; - overflow: hidden; - white-space: nowrap; - position: relative; +/* Heure */ +.afl-text-time { + font-size: 8.0vh !important; + font-weight: 800 !important; + color: #ffff00 !important; + line-height: 1 !important; + letter-spacing: -0.08vw !important; + margin: 0 !important; + padding: 0 !important; } -.afl-departs__vias { - font-size: 3.5vh; - font-weight: 400; - color: #cce0ff; - display: inline-block; - white-space: nowrap; - animation: afl-marquee 6.6s linear infinite; - padding-left: 100%; +.afl-row--compact .afl-text-time { + font-size: 8.0vh !important; + display: flex !important; + align-items: center !important; } -@keyframes afl-marquee { - 0% { transform: translate(0, 0); } - 100% { transform: translate(-100%, 0); } +/* Opérateur */ +.afl-text-op { + font-size: 5.0vh !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + text-transform: uppercase !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + max-width: 100% !important; + margin: 0 !important; + padding-bottom: 0.3vh !important; } -.afl-departs__col-platform { - width: 12vw; - flex-shrink: 0; - display: flex; - justify-content: flex-end; - padding-top: 0; +/* Destination */ +.afl-text-dest { + font-size: 8.0vh !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + white-space: nowrap !important; + margin: 0 !important; + padding: 0 !important; } -.afl-departs__platform-box { - background-color: white; - border-radius: 0.8vh; - width: 10vw; - height: 12.5vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-shadow: 0 0.5vh 1vh rgba(0,0,0,0.2); +.afl-row--compact .afl-text-dest { + font-size: 7.5vh !important; + display: flex !important; + align-items: center !important; } -.afl-departs__platform-label { - color: #012a6a; /* Matches background */ - font-size: 2.8vh; - font-weight: 700; - line-height: 1.2; +/* ========================================================================== + PARTIE BASSE (LIGNES ETENDUES) + ========================================================================== */ +.afl-bottom-row { + height: 7.5vh !important; + display: flex !important; + align-items: center !important; + margin-top: 1.0vh !important; + width: 100% !important; + overflow: hidden !important; } -.afl-departs__platform-value { - color: #012a6a; /* Matches background */ - font-size: 6.8vh; - font-weight: 700; - line-height: 1.1; +/* Numéro de train */ +.afl-text-num { + font-size: 4.8vh !important; + font-weight: 400 !important; + color: #ffffff !important; + line-height: 1 !important; + margin: 0 !important; } -.afl-departs__separator-wrapper { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 2vh; - display: flex; - align-items: center; - justify-content: center; +.afl-row--compact .afl-text-num { + font-size: 5.2vh !important; + font-weight: 400 !important; + margin: 0 !important; + display: flex !important; + align-items: center !important; } -.afl-departs__separator { - position: absolute; - height: 0.4vh; - background-color: #005eb8; - width: 96vw; - border-radius: 0.2vh; +/* ========================================================================== + STATUS PILLS (DIMENSIONS EXACTES SELON L'IMAGE 1) + ========================================================================== */ +.afl-pill { + display: inline-flex !important; + align-items: center !important; + justify-content: flex-start !important; + gap: 0.8vw !important; + padding: 0.5vh 1.4vw 0.5vh 0.6vw !important; + border-radius: 3.0vh !important; + height: 5.8vh !important; + margin: 0 !important; } -.afl-departs__train-illustration { - position: absolute; - z-index: 2; - background-color: #012a6a; /* matches background */ - padding: 0 1vw; +.afl-pill-text { + font-size: 3.6vh !important; + font-weight: 700 !important; + line-height: 1 !important; + white-space: nowrap !important; } -.afl-departs__train-illustration svg { - display: block; +.afl-pill--ontime { + background-color: #005fa9 !important; + color: #ffffff !important; } -body:has(.afl-departs__container) { - margin: 0; - padding: 0; - overflow: hidden; +.afl-pill--delayed { + background-color: #f8c898 !important; + color: #111111 !important; } -.afl-departs__container * { - box-sizing: border-box; +.afl-pill--suppressed { + background-color: #ff5c73 !important; + color: #111111 !important; } -.afl-departs__separator-wrapper { - position: relative; - width: 100%; - height: 10px; - display: flex; - align-items: center; - justify-content: center; +.afl-icon { + width: 4.0vh !important; + height: 4.0vh !important; + flex-shrink: 0 !important; } -.afl-departs__separator { - position: absolute; - height: 4px; - background-color: #007ac3; - width: calc(100% - 48px); - border-radius: 2px; +/* ========================================================================== + GARES DESSERVIES (VIAS - 'VIA' FIXE & DEFILEMENT DES GARES ULTRA-ACCELERE) + ========================================================================== */ +.afl-vias-container { + display: flex !important; + align-items: baseline !important; + white-space: nowrap !important; + width: 100% !important; + height: 100% !important; + overflow: hidden !important; } -.afl-departs__train-illustration { - position: absolute; - z-index: 2; - background-color: #01347d; /* match row background so it overlays the line */ - padding: 0 10px; +.afl-vias-label { + font-size: 3.6vh !important; + font-weight: 400 !important; + color: #ffffff !important; + margin-right: 0.5vw !important; + flex-shrink: 0 !important; + vertical-align: baseline !important; } -.afl-departs__row--compact { - height: 10.5vh; - align-items: center; - padding-top: 0; - padding-bottom: 0; +.afl-vias-scroll-wrapper { + flex: 1 !important; + overflow: hidden !important; + white-space: nowrap !important; + position: relative !important; + display: flex !important; + align-items: baseline !important; } -.afl-departs__row--compact .afl-departs__platform-box { - height: 8vh; +.afl-vias-inner { + display: inline-block !important; + white-space: nowrap !important; } -.afl-departs__row--compact .afl-departs__platform-label { - font-size: 1.8vh; +.afl-vias-inner--animating { + animation-name: afl-vias-marquee !important; + animation-timing-function: linear !important; + animation-iteration-count: infinite !important; + padding-left: 25% !important; } -.afl-departs__row--compact .afl-departs__platform-value { - font-size: 4vh; +@keyframes afl-vias-marquee { + 0% { + transform: translateX(0%); + } + 100% { + transform: translateX(-100%); + } } -.afl-departs__row--compact .afl-departs__col-time-status, -.afl-departs__row--compact .afl-departs__col-train-info, -.afl-departs__row--compact .afl-departs__col-destination, -.afl-departs__row--compact .afl-departs__col-platform { - padding-top: 0; +.afl-vias-stop { + font-size: 5.5vh !important; + font-weight: 400 !important; + color: #ffffff !important; + line-height: 1 !important; + vertical-align: baseline !important; +} + +.afl-vias-chevron { + color: #00c3ff !important; + font-weight: 800 !important; + margin: 0 0.4vw !important; + font-size: 5.5vh !important; + vertical-align: baseline !important; +} + +/* ========================================================================== + VOIE BOX (PROPORTIONS ET DESIGN STRICTEMENT CONFORMES A L'IMAGE) + ========================================================================== */ +.afl-voie-box { + background-color: #ffffff !important; + border-radius: 1.8vh !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + gap: 0.2vh !important; + width: 8.5vw !important; + height: 15.2vh !important; + padding: 0.5vh 0 !important; + box-shadow: 0 0.4vh 1.0vh rgba(0, 0, 0, 0.25) !important; +} + +.afl-voie-label { + color: #002d62 !important; + font-size: 3.5vh !important; + font-weight: 600 !important; + line-height: 1 !important; + letter-spacing: -0.02vw !important; + margin: 0 !important; +} + +.afl-voie-value { + color: #002d62 !important; + font-size: 9.8vh !important; + font-weight: 800 !important; + line-height: 0.9 !important; + margin: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.afl-voie-box--bus { + justify-content: center !important; + align-items: center !important; + padding: 0 !important; +} + +.afl-voie-box-compact { + background-color: #ffffff !important; + border-radius: 1.0vh !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 6.2vw !important; + height: 7.6vh !important; + padding: 0 !important; + box-shadow: 0 0.2vh 0.5vh rgba(0, 0, 0, 0.25) !important; } diff --git a/src/pages/afficheurs/afl/AFLDeparts.js b/src/pages/afficheurs/afl/AFLDeparts.js index 0d1a655..b88607c 100644 --- a/src/pages/afficheurs/afl/AFLDeparts.js +++ b/src/pages/afficheurs/afl/AFLDeparts.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import './AFLDeparts.css'; import supabase from '../../../lib/supabase'; import { fetchAllRows } from '../../../lib/fetchAllRows'; @@ -25,6 +25,74 @@ const formatTimeNoSeconds = (v) => { return String(v).slice(0,5).replace(':','h'); }; +const BusIcon = ({ size = '5.5vh', color = 'white', style = {} }) => ( + + + +); + +const ViasMarquee = ({ viaNames }) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [duration, setDuration] = useState(4); + + useEffect(() => { + const check = () => { + if (containerRef.current && textRef.current) { + const cWidth = containerRef.current.clientWidth; + const tWidth = textRef.current.scrollWidth; + if (tWidth > cWidth) { + setIsOverflowing(true); + // Vitesse de défilement multipliée par 2 (~280px/s) + setDuration(Math.max(1.0, tWidth / 280)); + } else { + setIsOverflowing(false); + } + } + }; + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, [viaNames]); + + if (!viaNames || viaNames.length === 0) { + return ( +
+ Direct +
+ ); + } + + return ( +
+ via +
+
+ {viaNames.map((v, i) => ( + + {i > 0 && >} + {v} + + ))} +
+
+
+ ); +}; + // (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) @@ -37,14 +105,10 @@ function Clock(){ 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 ( -
-
-
{hh}:{mm}
-
{ss}
-
+
+ {hh}:{mm}
); } @@ -58,6 +122,12 @@ export default function AFLDeparts(){ // gérer les +, décoder et remplacer les tirets par des espaces const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + // Support pour ?page=1, ?page=2 pour verrouiller l'affichage sur une page spécifique + const pageParam = params.get('page'); + const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10)) + ? Math.max(0, parseInt(pageParam, 10) - 1) + : null; + // debug flag activable via ?debug=1 or ?debug=true const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true'); const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]); @@ -79,9 +149,10 @@ export default function AFLDeparts(){ const [allHoraires, setAllHoraires] = useState([]); const [perturbations, setPerturbations] = useState([]); // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...) + // eslint-disable-next-line no-unused-vars const [stationType, setStationType] = useState(null); const [, setTravaux] = useState([]); - const [, setTravauxSubstitutions] = useState([]); + const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); const [, setLoading] = useState(false); const [, setError] = useState(null); // debug des données brutes renvoyées par Supabase @@ -248,6 +319,7 @@ export default function AFLDeparts(){ }, []); // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant) + // eslint-disable-next-line no-unused-vars const parseTimeHM = useCallback((s) => { if (!s) return null; if (typeof s === 'number') return s; @@ -259,6 +331,7 @@ export default function AFLDeparts(){ }, []); // retourne le threshold (en minutes) pour afficher le quai selon le type de gare + // eslint-disable-next-line no-unused-vars const thresholdForStationType = useCallback((type) => { if (!type) return 30; const t = String(type).trim().toLowerCase(); @@ -955,8 +1028,8 @@ export default function AFLDeparts(){ // Debug: log first few annotated horaires try { debug('annotated horaires sample', annotated.slice(0,6).map(a => ({ id: a.id, numero: a.numero_train || a.numero, _status: a._status, _displayTime: a._displayTime }))); } catch (e) {} - // afficher seulement les 9 premières lignes sur l'afficheur - setHoraires((annotated || []).slice(0, 9)); + // Enregistrer tous les horaires (sans limitation à 9 pour alimenter toutes les pages) + setHoraires(annotated || []); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); setTravauxSubstitutions(travauxSubstData || []); @@ -980,14 +1053,14 @@ export default function AFLDeparts(){ // Helpers pour perturbations (memoized plus haut) - const [pageIndex, setPageIndex] = useState(0); - const [showAlternateStatus, setShowAlternateStatus] = useState(false); - + const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0); + const [alternateDelay, setAlternateDelay] = useState(false); + useEffect(() => { - const alternateInterval = setInterval(() => { - setShowAlternateStatus(prev => !prev); + const delayTimer = setInterval(() => { + setAlternateDelay(prev => !prev); }, 2000); - return () => clearInterval(alternateInterval); + return () => clearInterval(delayTimer); }, []); const getPageBounds = useCallback((pIndex) => { @@ -996,9 +1069,13 @@ export default function AFLDeparts(){ return { start, end: start + 8 }; }, []); - const totalPages = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); + const totalPages = Math.max(1, horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8)); useEffect(() => { + if (lockedPageIndex !== null) { + setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); + return; + } const pageInterval = setInterval(() => { setPageIndex((prev) => { const tot = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); @@ -1007,241 +1084,266 @@ export default function AFLDeparts(){ }); }, 10000); // Change page every 10 seconds return () => clearInterval(pageInterval); - }, [horaires.length]); + }, [horaires.length, lockedPageIndex, totalPages]); const { start, end } = getPageBounds(pageIndex); const displayedHoraires = horaires.slice(start, end); + const isPage1 = pageIndex === 0; return ( -
- {/* HEADER */} -
-
-
- - - -
-
Départs
-
- -
- {totalPages > 1 && Array.from({ length: totalPages }).map((_, i) => ( -
+
+ {/* HEADER */} +
+
+ + {/* Chevrons >> */} + + {/* Silhouette tête */} + + {/* Silhouette corps + marche */} + + {/* Porte de train verticale */} + + +
Départs
+
+ +
+ {totalPages > 1 && Array.from({ length: totalPages }).map((_, i) => ( +
{i + 1}
))} -
+
-
- -
-
+
+ +
+ - {/* LISTE DES TRAINS */} -
    - {displayedHoraires.length === 0 ? ( -
  • -
    Aucun horaire disponible.
    -
  • - ) : displayedHoraires.map((h, idx) => { - const horaire = h || {}; - const op = (horaire.type_train || horaire.op || '').toUpperCase(); - const number = horaire.numero_train || horaire.number || horaire.numero || ''; - - let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); - const destination = horaire.gare_arrivee_nom || ''; - const platform = (horaire._displayPlatform || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || '').toString().trim(); - - const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); - const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); - const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + {/* ROWS */} +
      + {displayedHoraires.length === 0 ? ( +
    • +
      Aucun horaire disponible.
      +
    • + ) : displayedHoraires.map((h, idx) => { + const horaire = h || {}; + const isDetailed = isPage1 && idx < 4; - // Removed time = '' to stop alternation - - let showPlatform = !(status && status.label === 'supprimé') && Boolean(platform); - if (showPlatform) { - const threshold = thresholdForStationType(stationType); - let diffMinutes = null; - const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); - if (sched !== null) { - let baseDate = new Date(); - if (horaire._date) { - const parts = horaire._date.split('-'); - if (parts.length === 3) { - baseDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)); - } - } - baseDate.setHours(Math.floor(sched / 60), sched % 60, 0, 0); - const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); - if (!horaire._date) { - 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; - } - if (diffMinutes !== null) { - showPlatform = (diffMinutes <= threshold); - } else { - showPlatform = false; - } - } - - // Calcul des vias - const stopsList = getStops(horaire); - const viaNames = []; - if (Array.isArray(stopsList)) { - let isAfterStart = false; - for (const s of stopsList) { - const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; - if (!sName) continue; - const norm = normalizeName(sName); - if (norm === normalizeName(stationName || '')) { - isAfterStart = true; - continue; - } - if (isAfterStart) { - if (norm === normalizeName(destination || '')) { - break; - } - viaNames.push(sName); - } - } - } - const viasStr = viaNames.length > 0 ? viaNames.join(' > ') : ''; - const marqueeStyle = viasStr.length > 0 ? { animationDuration: `${Math.max(6, viasStr.length * 0.25)}s` } : {}; - - const isDelayed = status.label && String(status.label).toLowerCase().includes('retard'); - const isSuppressed = status.label && String(status.label).toLowerCase().includes('supprimé'); + const op = (horaire.type_train || horaire.op || horaire.type || '').toUpperCase(); + const number = horaire.numero_train || horaire.number || horaire.numero || ''; - let displayStatusText = "À l'heure"; - let delayVal = ''; - if (isDelayed) { - displayStatusText = status.label; - if (typeof status.delayMinutes === 'number') { - delayVal = `+${status.delayMinutes} min`; - displayStatusText = delayVal; + const rawPlatform = ( + horaire._displayPlatform || + horaire.quai_reel || + horaire.quai || + horaire.quai_prevu || + horaire.platform || + horaire.voie || + horaire.track || + '' + ).toString().trim(); + + const isCG = rawPlatform.toUpperCase() === 'CG' || + rawPlatform.toUpperCase().includes('CG') || + String(horaire.quai || '').toUpperCase().includes('CG') || + String(horaire.quai_reel || '').toUpperCase().includes('CG') || + String(horaire.quai_prevu || '').toUpperCase().includes('CG') || + String(horaire.platform || '').toUpperCase().includes('CG'); + + const isCarTER = isCG || + op.includes('CAR') || + op.includes('AUTOCAR') || + op.includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('BUS') || + String(horaire.type_train || '').toUpperCase().includes('CAR') || + String(horaire.type_train || '').toUpperCase().includes('BUS') || + String(horaire.libelle_type_train || '').toUpperCase().includes('CAR') || + String(horaire.libelle_type_train || '').toUpperCase().includes('BUS') || + (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero)); + + let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); + const destination = horaire.gare_arrivee_nom || ''; + + const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); + const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); + const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + + const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé'); + let showPlatform = !isSuppressed && Boolean(rawPlatform) && !isCG; + + const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard'); + const pillClass = isSuppressed ? 'afl-pill--suppressed' : isDelayed ? 'afl-pill--delayed' : 'afl-pill--ontime'; + + let delayVal = '+ 5 min'; + if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { + delayVal = `+ ${status.delayMinutes} min`; + } else if (status?.delayMinutes) { + delayVal = `+ ${status.delayMinutes} min`; + } else if (status?.label && status.label.includes('+')) { + delayVal = status.label; + } + + let delayDisplayText = alternateDelay ? 'retardé' : delayVal; + let pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayDisplayText : 'À l\'heure'; + + // Calcul des vias + const stopsList = getStops(horaire); + const viaNames = []; + if (Array.isArray(stopsList)) { + let isAfterStart = false; + for (const s of stopsList) { + const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; + if (!sName) continue; + const norm = normalizeName(sName); + if (norm === normalizeName(stationName || '')) { + isAfterStart = true; + continue; + } + if (isAfterStart) { + if (norm === normalizeName(destination || '')) { + break; + } + viaNames.push(sName); + } } } - if (isSuppressed) displayStatusText = "Supprimé"; - const isBus = op.includes('CAR') || op.includes('AUTOCAR') || op.includes('BUS') || (horaire.mode && horaire.mode.toLowerCase().includes('bus')); + const renderPillIcon = () => { + if (isSuppressed) { + return ( + + + + + ); + } + if (isDelayed) { + return ( + + + + + + ); + } + return ( + + + + + ); + }; - const isCompact = pageIndex > 0 && idx > 0; - const showCompactStatus = isCompact; - - return ( - -
    • -
      - {showCompactStatus && showAlternateStatus ? ( -
      - {isSuppressed ? ( - - - - - ) : isDelayed ? ( - - - - - ) : ( - - - - - )} - {displayStatusText} + return ( +
    • + + {/* TIME & PILL */} +
      + {isDetailed ? ( + <> +
      +
      {time}
      - ) : ( - <> -
      {time}
      - {!isCompact && ( -
      - {isSuppressed ? ( - - - - - ) : isDelayed ? ( - - - - - ) : ( - - - - - )} - {displayStatusText} -
      - )} - - )} -
      - -
      -
      {op}
      - {!isCompact &&
      {number}
      } -
      - -
      -
      - {isBus && ( - - - - )} - -
      - {!isCompact && ( -
      - {viasStr ? ( - <> - via -
      -
      {viasStr}
      -
      - - ) : ( - direct - )} -
      - )} -
      - -
      - {isSuppressed ? ( - - - - - ) : showPlatform ? ( -
      - {!isBus &&
      Voie
      } -
      - {isBus ? ( - - - - ) : platform} +
      +
      + {renderPillIcon()} + {pillText}
      - ) : null} -
      -
    • - {/* Séparateur pour toutes les lignes sauf la dernière. */} - {idx < displayedHoraires.length - 1 && ( -
      -
      -
      - )} - - ); - })} -
    -
+ + ) : ( + alternateDelay ? ( +
+ {renderPillIcon()} + {pillText} +
+ ) : ( +
{time}
+ ) + )} +
+ + {/* OP & NUMBER */} +
+ {isDetailed ? ( + <> +
+
{op}
+
+
+
{number}
+
+ + ) : ( + alternateDelay ? ( +
{op || number}
+ ) : ( +
{number || op}
+ ) + )} +
+ + {/* DESTINATION & VIAS */} +
+ {isDetailed ? ( + <> +
+ {isCarTER && } + +
+
+ +
+ + ) : ( +
+ {isCarTER && } + +
+ )} +
+ + {/* VOIE */} +
+ {isDetailed ? ( + isSuppressed ? ( + + + + + ) : isCarTER ? ( +
+ +
+ ) : showPlatform ? ( +
+
Voie
+
{rawPlatform}
+
+ ) : null + ) : ( + isSuppressed ? ( + + + + + ) : isCarTER ? ( +
+ +
+ ) : null + )} +
+ + + ); + })} + +
); } \ No newline at end of file diff --git a/src/pages/afficheurs/afl/AFLDepartsQuais.css b/src/pages/afficheurs/afl/AFLDepartsQuais.css index a57be6f..f9e46f8 100644 --- a/src/pages/afficheurs/afl/AFLDepartsQuais.css +++ b/src/pages/afficheurs/afl/AFLDepartsQuais.css @@ -1,335 +1,299 @@ -/* Déclaration des fontes Achemine (dans public/fonts/) */ -@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; -} -@font-face { - font-family: 'Achemine'; - src: url('/public/fonts/achemine_italic.ttf') format('truetype'); - font-weight: 400; - font-style: italic; - font-display: swap; +/* ========================================================================== + AFL DEPARTS QUAIS (ECRAN DE QUAI SNCF - PROCHAIN DEPART) - CSS + ========================================================================== */ + +/* Conteneur Global Plein Ecran */ +.afl-q-container { + font-family: 'Achemine', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important; + background: radial-gradient(circle at 60% 35%, #151a4a 0%, #0c0f2e 100%) !important; + background-color: #0c0f2e !important; + color: #ffffff !important; + width: 100vw !important; + height: 100vh !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + margin: 0 !important; + padding: 0 !important; + box-sizing: border-box !important; + user-select: none !important; + z-index: 10000 !important; } -.afl-quais-departs__container { - --bg-color: #042054; /* Deep dark blue background */ - --header-bg: #083c84; /* Lighter vibrant blue header */ - --accent-cyan: #00c3e3; /* Cyan timeline color */ - --accent-yellow: #cce815; /* Bright chartreuse/lime-yellow for departure time */ - --text-grey: #a0b2d6; /* Light grey-blue for train info */ +.afl-q-container * { + box-sizing: border-box !important; } -.afl-quais-departs__container { - display: flex; - flex-direction: column; - width: 100vw; - height: 100vh; - background-color: var(--bg-color); - color: white; - font-family: 'Achemine', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - overflow: hidden; - position: relative; +/* ========================================================================== + HEADER (10.5vh) + ========================================================================== */ +.afl-q-header { + height: 10.5vh !important; + min-height: 10.5vh !important; + max-height: 10.5vh !important; + background-color: #38466e !important; /* Couleur bleu ardoise exacte */ + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 2.5vw !important; + border-bottom: 1.5px solid rgba(255, 255, 255, 0.12) !important; + z-index: 10 !important; } -/* --- HEADER --- */ -.afl-quais-departs__header { - height: 12vh; - background-color: var(--header-bg); - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 3vw; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - z-index: 10; - box-sizing: border-box; +.afl-q-header-left { + display: flex !important; + align-items: center !important; + gap: 1.2vw !important; } -.afl-quais-departs__header-left { - display: flex; - align-items: center; - gap: 1.2vw; +.afl-q-header-icon { + width: 5.2vh !important; + height: 5.2vh !important; + flex-shrink: 0 !important; } -.afl-quais-departs__header-title { - font-size: 5vh; - font-weight: 700; - letter-spacing: -0.02em; +.afl-q-header-title { + font-size: 5.0vh !important; + font-weight: 700 !important; + color: #ffffff !important; + letter-spacing: -0.03vw !important; + line-height: 1 !important; } -.afl-quais-departs__header-clock { - font-size: 5vh; - font-weight: 700; +.afl-q-header-clock { + font-size: 5.0vh !important; + font-weight: 700 !important; + color: #ffffff !important; + letter-spacing: -0.03vw !important; + line-height: 1 !important; } -/* --- MAIN CONTENT --- */ -.afl-quais-departs__main { - flex: 1; - display: flex; - position: relative; - box-sizing: border-box; +/* ========================================================================== + CORPS PRINCIPAL (75vh) + ========================================================================== */ +.afl-q-main { + flex: 1 !important; + display: flex !important; + flex-direction: row !important; + padding: 2.0vh 2.5vw 0.5vh 2.5vw !important; + gap: 3.5vw !important; + overflow: hidden !important; + position: relative !important; } -/* --- LEFT PANE (GLUED TO LEFT/TOP, ROUNDED BOTTOM RIGHT) --- */ -.afl-quais-departs__left-pane { - position: absolute; - top: 0; - left: 0; - width: 35vw; - height: 38vh; - background: rgba(255, 255, 255, 0.07); - border-radius: 0 0 24px 0; /* Only bottom-right is rounded */ - padding: 4vh 3vw; - display: flex; - flex-direction: column; - box-sizing: border-box; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-right: 1px solid rgba(255, 255, 255, 0.08); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); +/* -------------------------------------------------------------------------- + CORPS PRINCIPAL (MAIN) + -------------------------------------------------------------------------- */ +.afl-q-main { + flex: 1 !important; + display: flex !important; + align-items: flex-start !important; /* Hauteur dynamique du panneau gauche */ + padding: 2.5vh 2.5vw 1.5vh 2.5vw !important; + gap: 3.5vw !important; + overflow: hidden !important; + position: relative !important; } -.afl-quais-departs__time-status { - display: flex; - align-items: center; - gap: 1vw; - margin-bottom: 1.5vh; +/* -------------------------------------------------------------------------- + COLONNE GAUCHE (CARTE DU TRAIN - HAUTEUR DYNAMIQUE) + -------------------------------------------------------------------------- */ +.afl-q-left-card { + width: 29.5vw !important; + min-width: 29.5vw !important; + max-width: 29.5vw !important; + height: auto !important; /* Dynamique selon le nombre de destinations */ + background-color: rgba(32, 42, 78, 0.90) !important; + border: 1.5px solid rgba(255, 255, 255, 0.14) !important; + border-radius: 1.4vh !important; + padding: 1.8vh 1.6vw !important; + display: flex !important; + flex-direction: column !important; + gap: 1.4vh !important; + box-shadow: 0 0.8vh 2.0vh rgba(0, 0, 0, 0.35) !important; + position: relative !important; } -.afl-quais-departs__time { - font-size: 7.5vh; - font-weight: 800; - color: var(--accent-yellow); - line-height: 1; +/* En-tête de carte : Heure & Badge Statut */ +.afl-q-card-top { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding-bottom: 1.4vh !important; + border-bottom: 1.5px solid rgba(255, 255, 255, 0.14) !important; } -.afl-quais-departs__status-pill { - display: inline-flex; - align-items: center; - background-color: #005eb8; - border-radius: 3vh; - padding: 0.6vh 0.8vw; - font-size: 2.2vh; - font-weight: 600; - color: white; - width: max-content; +.afl-q-card-time { + font-size: 5.6vh !important; + font-weight: 800 !important; + color: #ffff00 !important; /* Même couleur jaune que sur AFLDeparts.js */ + line-height: 1 !important; + letter-spacing: -0.04vw !important; } -.afl-quais-departs__status-pill svg { - margin-right: 0.5vw; +/* Pastille Statut */ +.afl-q-pill { + display: inline-flex !important; + align-items: center !important; + gap: 0.6vw !important; + padding: 0.5vh 1.0vw 0.5vh 0.7vw !important; + border-radius: 2.5vh !important; + background-color: #45527a !important; + font-size: 2.4vh !important; + font-weight: 700 !important; + color: #ffffff !important; + line-height: 1 !important; } -.afl-quais-departs__status-pill--delayed { - background-color: #ff9e43; - color: #111; +.afl-q-pill--delayed { + background-color: #f8c898 !important; + color: #111111 !important; } -.afl-quais-departs__status-pill--suppressed { - background-color: #ff4c5b; - color: #111; +.afl-q-pill--suppressed { + background-color: #ff5c73 !important; + color: #111111 !important; } -.afl-quais-departs__destination { - font-size: 5.5vh; - font-weight: 700; - line-height: 1.15; - color: #ffffff; - margin-bottom: 1.5vh; - word-wrap: break-word; +.afl-q-pill-icon { + width: 2.6vh !important; + height: 2.6vh !important; + flex-shrink: 0 !important; } -.afl-quais-departs__train-info { - font-size: 3vh; - color: var(--text-grey); - display: flex; - gap: 0.8vw; +/* Destinations & Numéros */ +.afl-q-dest-block { + padding-bottom: 1.4vh !important; + border-bottom: 1.5px solid rgba(255, 255, 255, 0.14) !important; + display: flex !important; + flex-direction: column !important; + gap: 0.6vh !important; } -.afl-quais-departs__train-type { - font-weight: 800; +.afl-q-dest-title { + font-size: 5.6vh !important; /* Grande police pour la gare de destination */ + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1.05 !important; + letter-spacing: -0.03vw !important; + word-break: break-word !important; } -.afl-quais-departs__train-number { - font-weight: 500; +.afl-q-dest-sub { + font-size: 2.6vh !important; + font-weight: 700 !important; + color: #9bb0d8 !important; + letter-spacing: 0.04vw !important; + text-transform: uppercase !important; + line-height: 1 !important; + margin-top: 0.4vh !important; } -/* --- RIGHT PANE (TIMELINE) --- */ -.afl-quais-departs__right-pane { - position: absolute; - top: 4vh; - left: 38vw; - width: 58vw; - bottom: 12vh; - display: flex; - flex-direction: column; - justify-content: center; /* Center stops list vertically */ - box-sizing: border-box; +/* Bas de la carte : Logo du type de train */ +.afl-q-card-footer { + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + padding-top: 0.4vh !important; } -.afl-quais-departs__stops-window { - width: 100%; - max-height: 100%; - overflow: hidden; - position: relative; +.afl-q-train-logo-box { + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + max-height: 4.8vh !important; } -.afl-quais-departs__stops-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - width: 100%; +.afl-q-train-logo-img { + max-height: 4.5vh !important; + max-width: 15vw !important; + object-fit: contain !important; } -.afl-quais-departs__stop-item { - display: flex; - align-items: center; - height: 6.2vh; - position: relative; - box-sizing: border-box; +.afl-q-train-logo-text { + border: 1.5px solid rgba(255, 255, 255, 0.85) !important; + border-radius: 0.6vh !important; + padding: 0.3vh 0.8vw !important; + font-size: 2.5vh !important; + font-style: italic !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: 0.06vw !important; } -/* Timeline Indicator (Line segment + Dot) */ -.afl-quais-departs__stop-indicator { - position: relative; - width: 4vw; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; +/* -------------------------------------------------------------------------- + COLONNE DROITE (TIMELINE / PLAN DE LIGNE ARBORESCENT) + -------------------------------------------------------------------------- */ +.afl-q-timeline-area { + flex: 1 !important; + height: 100% !important; + display: flex !important; + flex-direction: column !important; + justify-content: flex-start !important; + overflow: hidden !important; + position: relative !important; } -.afl-quais-departs__stop-line { - position: absolute; - left: 50%; - top: 0; - bottom: 0; - width: 0.6vh; - transform: translateX(-50%); - background-color: var(--accent-cyan); - box-shadow: 0 0 6px rgba(0, 195, 227, 0.4); - z-index: 1; +.afl-q-timeline-svg { + width: 100% !important; + height: 100% !important; + overflow: visible !important; } -/* Adjust line ends for first and last stops */ -.afl-quais-departs__stop-item.is-first .afl-quais-departs__stop-line { - top: 50%; +/* ========================================================================== + FOOTER : BOX VOIE EN BAS À DROITE (SANS REPÈRES DE QUAI NI COMPOSITION) + ========================================================================== */ +.afl-q-footer { + height: 14vh !important; + min-height: 14vh !important; + max-height: 14vh !important; + display: flex !important; + align-items: flex-end !important; + justify-content: flex-end !important; + padding: 0 2.5vw 1.8vh 2.5vw !important; + position: relative !important; + z-index: 10 !important; } -.afl-quais-departs__stop-item.is-last .afl-quais-departs__stop-line { - bottom: 50%; +/* -------------------------------------------------------------------------- + BOX VOIE (BLANCHE / PROPORTIONS STRICTES) + -------------------------------------------------------------------------- */ +.afl-q-voie-box { + background-color: #ffffff !important; + border-radius: 1.2vh !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + width: 7.2vw !important; + min-width: 7.2vw !important; + height: 12.5vh !important; + padding: 0.4vh 0 !important; + box-shadow: 0 0.6vh 1.5vh rgba(0, 0, 0, 0.35) !important; + flex-shrink: 0 !important; } -/* Standard dot */ -.afl-quais-departs__stop-dot { - width: 1.4vh; - height: 1.4vh; - border-radius: 50%; - background-color: var(--accent-cyan); - box-shadow: 0 0 6px rgba(0, 195, 227, 0.6); - z-index: 2; - position: relative; +.afl-q-voie-label { + color: #002447 !important; + font-size: 2.4vh !important; + font-weight: 600 !important; + line-height: 1 !important; + letter-spacing: -0.02vw !important; + margin: 0 !important; } -/* Terminus dot */ -.afl-quais-departs__stop-dot.is-terminus { - width: 2.6vh; - height: 2.6vh; - background-color: var(--bg-color); /* Hollow effect */ - border: 0.6vh solid var(--accent-cyan); - box-shadow: 0 0 8px rgba(0, 195, 227, 0.8); - border-radius: 50%; +.afl-q-voie-value { + color: #002447 !important; + font-size: 6.8vh !important; + font-weight: 800 !important; + line-height: 0.9 !important; + margin: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; } -/* Stop Names */ -.afl-quais-departs__stop-name { - font-size: 3.8vh; - font-weight: 500; - color: #ffffff; - padding-left: 1vw; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.afl-quais-departs__stop-name.is-terminus { - font-size: 4.5vh; - font-weight: 700; - color: #ffffff; - padding-left: 1vw; -} - -/* --- BOTTOM TRAIN ILLUSTRATION --- */ -.afl-quais-departs__bottom-container { - position: absolute; - bottom: 3.5vh; - left: 38vw; - width: 42vw; - display: flex; - justify-content: center; - align-items: center; - box-sizing: border-box; -} - -.afl-quais-departs__train-illustration { - display: flex; - justify-content: center; - opacity: 0.85; -} - -/* --- VOIE BOX (PLATFORM BOX) --- */ -.afl-quais-departs__platform-box { - position: absolute; - bottom: 2vh; - right: 2vw; - background-color: #ffffff; - border-radius: 8px; - width: 6.5vw; - height: 13vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.35); - z-index: 10; - box-sizing: border-box; -} - -.afl-quais-departs__platform-label { - font-size: 2.2vh; - color: var(--bg-color); - font-weight: 700; - text-transform: capitalize; - line-height: 1; - margin-bottom: 0.5vh; -} - -.afl-quais-departs__platform-value { - font-size: 7vh; - color: var(--bg-color); - font-weight: 800; - line-height: 1; -} - -/* --- NO DEPARTURE / LOADING STATE --- */ -.afl-quais-departs__no-departure { - display: flex; - justify-content: center; - align-items: center; - height: 88vh; - font-size: 6vh; - font-weight: 600; - color: #a4c4e0; -} diff --git a/src/pages/afficheurs/afl/AFLDepartsQuais.js b/src/pages/afficheurs/afl/AFLDepartsQuais.js index d21ba89..3eaa388 100644 --- a/src/pages/afficheurs/afl/AFLDepartsQuais.js +++ b/src/pages/afficheurs/afl/AFLDepartsQuais.js @@ -3,6 +3,7 @@ import supabase from '../../../lib/supabase'; import { fetchAllRows } from '../../../lib/fetchAllRows'; import './AFLDepartsQuais.css'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import Logo from '../../../components/Logo'; // Helper utilitaire : format HH:MM sans secondes (ex: 15:57) const formatTimeNoSeconds = (v) => { @@ -667,7 +668,6 @@ const AFLDepartsQuais = () => { const map = buildPerturbMap(filteredPerturb, parsedHoraires); const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb, null, gareDisplay, serviceDateStr, map); - // Now filter by platform specific to this quai const candidatesOnPlatform = annotated.filter(h => { const stopsList = getStops(h); let stopForStation = null; @@ -675,7 +675,8 @@ const AFLDepartsQuais = () => { 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(); - return quaiParam && platform.toLowerCase() === quaiParam.toLowerCase(); + if (!quaiParam) return true; + return platform.toLowerCase() === quaiParam.toLowerCase(); }); if (candidatesOnPlatform.length > 0) { @@ -696,202 +697,300 @@ const AFLDepartsQuais = () => { return () => clearInterval(interval); }, [fetchData]); - if (loading) return
; - if (!nextDeparture) return ( -
-
-
- - - - - - - -
Prochain départ
-
-
{formatTimeNoSeconds(currentTime)}
+ if (loading) return
; + + if (!nextDeparture) { + return ( +
+
+
+ + + + + + +
Prochain départ
+
+
{formatTimeNoSeconds(currentTime)}
+
+
+ Aucun départ prévu
-
Aucun départ prévu
-
- ); +
+ {quaiParam && ( +
+
Voie
+
{quaiParam}
+
+ )} +
+
+ ); + } - const stops = getStops(nextDeparture); - let filteredStops = []; + const trainData = nextDeparture; + + const isSuppressed = trainData._status && String(trainData._status.label || '').toLowerCase().includes('supprimé'); + const isDelayed = trainData._status && String(trainData._status.label || '').toLowerCase().includes('retard'); - if (stops.length > 0 && gareDisplay) { - const stationIndex = stops.findIndex(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); - if (stationIndex !== -1) { - filteredStops = stops.slice(stationIndex + 1); - } else { - const firstStopName = normalizeName((stops[0].gare_nom || stops[0].name || stops[0].nom || '').toString()); - if (firstStopName && firstStopName === normalizeName(gareDisplay)) { - filteredStops = stops.slice(1); - } - } + let delayVal = '+ 5 min'; + if (typeof trainData._status?.delayMinutes === 'number' && trainData._status.delayMinutes > 0) { + delayVal = `+ ${trainData._status.delayMinutes} min`; + } else if (trainData._status?.label && trainData._status.label.includes('+')) { + delayVal = trainData._status.label; } - const destName = nextDeparture.gare_arrivee_nom || nextDeparture.gare_arrivee || 'Terminus'; - if (filteredStops.length > 0) { - const lastStopName = normalizeName((filteredStops[filteredStops.length - 1].gare_nom || filteredStops[filteredStops.length - 1].name || filteredStops[filteredStops.length - 1].nom || '').toString()); - const normDest = normalizeName(destName); - if (lastStopName !== normDest && !lastStopName.includes(normDest) && !normDest.includes(lastStopName)) { - filteredStops.push({ gare_nom: destName, isLast: true }); - } else { - filteredStops[filteredStops.length - 1].isLast = true; - } - } else { - filteredStops.push({ gare_nom: destName, isLast: true }); - } + const pillClass = isSuppressed ? 'afl-q-pill--suppressed' : isDelayed ? 'afl-q-pill--delayed' : ''; + const pillText = isSuppressed ? 'Supprimé' : isDelayed ? delayVal : "À l'heure"; - // Deduplicate stops by name - const seenStops = new Set(); - filteredStops = filteredStops.filter(s => { - const name = normalizeName((s.gare_nom || s.name || s.nom || s.stop_name || '').toString()); - if (!name) return false; - if (seenStops.has(name)) return false; - seenStops.add(name); - return true; - }); - - const isSuppressed = nextDeparture._status && nextDeparture._status.label === 'supprimé'; - const isDelayed = nextDeparture._status && nextDeparture._status.label && String(nextDeparture._status.label).toLowerCase().includes('retard'); - let displayStatusText = "À l'heure"; - if (isSuppressed) displayStatusText = "Supprimé"; - else if (isDelayed) { - if (typeof nextDeparture._status.delayMinutes === 'number') { - displayStatusText = `+${nextDeparture._status.delayMinutes} min`; - } else { - displayStatusText = nextDeparture._status.label; + const renderPillIcon = () => { + if (isSuppressed) { + return ( + + + + + ); } - } + if (isDelayed) { + return ( + + + + + + ); + } + return ( + + + + + ); + }; - const displayTime = nextDeparture._displayTime || formatTimeNoSeconds(nextDeparture.heure_depart || nextDeparture.time || nextDeparture.heure || ''); - const trainType = nextDeparture.type_train || nextDeparture.op || 'TER'; - const trainNum = nextDeparture.numero_train || nextDeparture.numero || nextDeparture.number || ''; - - const stopsList = getStops(nextDeparture); + const displayTime = trainData._displayTime || formatTimeNoSeconds(trainData.heure_depart || trainData.time || trainData.heure || ''); + const trainType = trainData.type_train || trainData.op || 'TER'; + const trainNum = trainData.numero_train || trainData.numero || trainData.number || ''; + const destName = trainData.gare_arrivee_nom || trainData.gare_arrivee || 'Terminus'; + + const stopsList = getStops(trainData); let stopForStation = null; if (gareDisplay && Array.isArray(stopsList)) { stopForStation = stopsList.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); } - const platformStr = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || nextDeparture.quai_reel || nextDeparture.voie || nextDeparture.quai || quaiParam || '').toString().trim(); + const platformStr = ((stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || trainData.quai_reel || trainData.voie || trainData.quai || quaiParam || '').toString().trim(); - const shouldScroll = filteredStops.length > 10; - // eslint-disable-next-line no-unused-vars - const scrollStyle = shouldScroll ? { - animation: `scrollStops ${filteredStops.length * 2.2}s linear infinite` - } : {}; + // Extraction des destinations (prise en compte des trains multi-branches / tranches) + let destinations = []; + if (Array.isArray(trainData.tranches) && trainData.tranches.length > 0) { + destinations = trainData.tranches.map(t => ({ + name: t.gare_arrivee_nom || t.destination || t.nom, + op: t.type_train || t.op || trainType, + number: t.numero_train || t.number || trainNum + })); + } else if (destName.includes('/')) { + destinations = destName.split('/').map(d => ({ + name: d.trim(), + op: trainType, + number: trainNum + })); + } else if (destName.includes(' - ') && (destName.toLowerCase().includes('marseille') || destName.toLowerCase().includes('montpellier'))) { + destinations = [ + { name: 'Marseille Saint-Charles', op: trainType, number: trainNum }, + { name: 'Montpellier Saint-Roch', op: trainType, number: trainNum } + ]; + } else { + destinations = [{ + name: destName, + op: trainType, + number: trainNum + }]; + } + + // Formatage des lignes de destinations (découpage élégant sur 2 lignes si nécessaire) + const formatDestLines = (name) => { + if (!name) return ['Terminus']; + const clean = name.trim(); + if (clean.includes('Marseille Saint-Charles')) return ['Marseille Saint-', 'Charles']; + if (clean.includes('Montpellier Saint-Roch')) return ['Montpellier', 'Saint-Roch']; + if (clean.includes('Paris Gare de Lyon')) return ['Paris', 'Gare de Lyon']; + if (clean.includes('Paris Montparnasse')) return ['Paris', 'Montparnasse']; + if (clean.includes('Bourg-en-Bresse')) return ['Bourg-en-', 'Bresse']; + if (clean.length > 12) { + const parts = clean.split(' '); + if (parts.length >= 2) { + const mid = Math.ceil(parts.length / 2); + return [parts.slice(0, mid).join(' '), parts.slice(mid).join(' ')]; + } + } + return [clean]; + }; + + // Calcul dynamique des arrêts desservis (aval) + const currentStationName = gareDisplay || (stopForStation && (stopForStation.gare_nom || stopForStation.nom || stopForStation.name)) || (stopsList[0] && (stopsList[0].gare_nom || stopsList[0].nom || stopsList[0].name)) || 'Départ'; + + let downstreamStops = []; + if (Array.isArray(stopsList) && stopsList.length > 0) { + const normCurrent = normalizeName(currentStationName); + const startIdx = stopsList.findIndex(s => { + const sName = normalizeName((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)); + }); + + const rawDownstream = startIdx !== -1 ? stopsList.slice(startIdx + 1) : stopsList.slice(1); + + const seen = new Set(); + 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 norm = normalizeName(name); + if (name && norm !== normCurrent && !seen.has(norm)) { + seen.add(norm); + downstreamStops.push(name); + } + }); + } + + // Si aucun arrêt intermédiaire n'est trouvé, afficher la/les destination(s) + if (downstreamStops.length === 0) { + if (destName) { + if (destName.includes('/')) { + downstreamStops = destName.split('/').map(d => d.trim()); + } else { + downstreamStops = [destName]; + } + } + } return ( -
- {/* Header */} -
-
- - {/* Chevrons / motion lines */} - - {/* Train icon */} - - - - - -
Prochain départ
+
+ {/* HEADER (10.5vh) */} +
+
+ + + + + + +
Prochain départ
-
{formatTimeNoSeconds(currentTime)}
-
+
{formatTimeNoSeconds(currentTime)}
+ -
- {/* Left Pane (Floating Card) */} -
-
-
{displayTime}
-
- {isSuppressed ? ( - - - - - ) : isDelayed ? ( - - - - - ) : ( - - - - - )} - {displayStatusText} + {/* CORPS PRINCIPAL */} +
+ {/* PANNEAU GAUCHE : CARTE D'INFORMATION DU TRAIN */} +
+ {/* Ligne Heure de départ & Statut */} +
+
{displayTime}
+
+ {renderPillIcon()} + {pillText}
-
{destName}
-
- {trainType} - {trainNum} -
-
- {/* Right Pane (Timeline) */} -
-
- -
    - {/* Main List */} - {filteredStops.map((s, i) => { - const sname = s.gare_nom || s.nom || s.name || s.stop_name || ''; - const isFirst = !shouldScroll && i === 0; - const isLastItem = s.isLast || i === filteredStops.length - 1; - const isLast = !shouldScroll && isLastItem; - + {/* Blocs Destinations dynamiques selon l'horaire */} + {destinations.map((d, i) => { + const lines = formatDestLines(d.name); + return ( +
    + {lines.map((line, lIdx) => ( +
    {line}
    + ))} +
    + {d.op} {d.number} +
    +
    + ); + })} + + {/* Pied de carte : Logo du type de train (sans icônes de services) */} +
    + +
    + + + {/* PANNEAU DROIT : TIMELINE ARBORESCENTE DYNAMIQUE */} +
    + + {/* 3 mini-points cyan au sommet */} + + + + + {/* Station 1: Gare actuelle */} + + + + {currentStationName} + + + {/* Tracé dynamique des arrêts aval */} + {downstreamStops.length <= 1 ? ( + + + + + {downstreamStops[0] || destName} + + + ) : ( + (() => { + const N = downstreamStops.length; + const yStart = 160; + const yEnd = 500; + const step = Math.min(65, (yEnd - yStart) / Math.max(1, N - 1)); + const lineEndY = yStart + (N - 1) * step; + return ( -
  • -
    -
    -
    -
    -
    {sname}
    -
  • + + + {downstreamStops.map((stopName, sIdx) => { + const isLast = sIdx === N - 1; + const posY = yStart + sIdx * step; + + if (!isLast) { + return ( + + + + {stopName} + + + ); + } + return ( + + + + {stopName} + + + ); + })} + ); - })} - -
-
-
-
- - {/* Symmetrical train outline under the stops list */} -
-
- - - - - - - - - + })() + )} + + + + {/* FOOTER : BOX VOIE EN BAS À DROITE (SANS REPÈRES DE QUAI NI COMPOSITION) */} +
+
+
Voie
+
{platformStr}
-
- - {platformStr && ( -
-
Voie
-
{platformStr}
-
- )} +
); }; diff --git a/src/pages/afficheurs/eva/EVAArrivees.css b/src/pages/afficheurs/eva/EVAArrivees.css new file mode 100644 index 0000000..b981056 --- /dev/null +++ b/src/pages/afficheurs/eva/EVAArrivees.css @@ -0,0 +1,476 @@ +/* ========================================================================== + EVA ARRIVEES (AFFICHEUR VOYAGEURS EVA) - CSS + ========================================================================== */ + +/* Container Principal */ +.eva-arr-container { + font-family: 'Avenir', 'Achemine', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important; + height: 100vh !important; + width: 100vw !important; + display: flex !important; + flex-direction: column !important; + background-color: #000824 !important; /* Bleu nuit très profond EVA */ + overflow: hidden !important; + color: #ffffff !important; + margin: 0 !important; + padding: 1.2vh 1.8vw !important; + box-sizing: border-box !important; + user-select: none !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; +} + +/* ========================================================================== + HEADER (En-tête EVA) + ========================================================================== */ +.eva-arr-header { + height: 9.5vh !important; + min-height: 9.5vh !important; + max-height: 9.5vh !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 0.5vw !important; + margin-bottom: 1.0vh !important; + box-sizing: border-box !important; +} + +.eva-arr-header-left { + display: flex !important; + align-items: center !important; + gap: 1.0vw !important; +} + +.eva-arr-header-icon { + width: 5.5vh !important; + height: 5.5vh !important; + flex-shrink: 0 !important; + color: #00d68f !important; +} + +.eva-arr-header-titles { + display: flex !important; + align-items: baseline !important; + gap: 0.6vw !important; + white-space: nowrap !important; +} + +.eva-arr-header-title-main { + font-size: 5.2vh !important; + font-weight: 900 !important; + color: #ffffff !important; + letter-spacing: 0.04vw !important; + line-height: 1 !important; + text-transform: uppercase !important; +} + +.eva-arr-header-slash { + font-size: 3.8vh !important; + font-weight: 700 !important; + color: #00d68f !important; + line-height: 1 !important; +} + +.eva-arr-header-title-sub { + font-size: 3.2vh !important; + font-weight: 700 !important; + color: #00d68f !important; + letter-spacing: 0.02vw !important; + line-height: 1 !important; + text-transform: uppercase !important; +} + +.eva-arr-header-right { + display: flex !important; + align-items: center !important; +} + +/* Horloge EVA */ +.eva-arr-clock-box { + border: 2.2px solid #00d68f !important; + background-color: #000824 !important; + border-radius: 8px !important; + padding: 0.5vh 1.0vw !important; + display: flex !important; + align-items: baseline !important; + gap: 0.5vw !important; + box-shadow: 0 0 10px rgba(0, 214, 143, 0.15) !important; +} + +.eva-arr-clock-main { + font-size: 4.8vh !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + letter-spacing: 0.02vw !important; +} + +.eva-arr-clock-seconds { + font-size: 3.8vh !important; + font-weight: 800 !important; + color: #00d68f !important; + line-height: 1 !important; +} + +/* ========================================================================== + LISTE DES HORAIRES (Cartes) + ========================================================================== */ +.eva-arr-list { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: flex-start !important; + gap: 1.2vh !important; + padding: 0 !important; + margin: 0 !important; + list-style: none !important; + overflow: hidden !important; +} + +.eva-arr-row { + background-color: #e9eef6 !important; + border-radius: 10px !important; + display: flex !important; + align-items: stretch !important; + height: calc((100% - 3.6vh) / 4) !important; + min-height: 12vh !important; + box-sizing: border-box !important; + overflow: hidden !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25) !important; +} + +/* -------------------------------------------------------------------------- + COLONNE GAUCHE : HEURE & STATUT + -------------------------------------------------------------------------- */ +.eva-arr-col-time { + width: 14.5vw !important; + min-width: 14.5vw !important; + max-width: 14.5vw !important; + background-color: #d7e1f0 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; + padding: 0.8vh 0.6vw !important; + box-sizing: border-box !important; + border-top-left-radius: 10px !important; + border-bottom-left-radius: 10px !important; +} + +.eva-arr-time-value { + font-size: 5.6vh !important; + font-weight: 900 !important; + color: #000c2f !important; + line-height: 1 !important; + margin-bottom: 0.6vh !important; + letter-spacing: -0.02vw !important; +} + +/* Pastilles de statut */ +.eva-arr-status-pill { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 0.4vw !important; + padding: 0.3vh 0.9vw !important; + border-radius: 20px !important; + box-sizing: border-box !important; + height: 3.8vh !important; +} + +.eva-arr-status-pill--ontime { + background-color: #d7e1f0 !important; + border: 1.8px solid #7d95b5 !important; + color: #1a2b4c !important; +} + +.eva-arr-status-pill--delayed { + background-color: #ffa74f !important; + color: #000c2f !important; +} + +.eva-arr-status-pill--suppressed { + background-color: #ff5c73 !important; + color: #ffffff !important; +} + +.eva-arr-status-icon { + width: 2.5vh !important; + height: 2.5vh !important; + flex-shrink: 0 !important; +} + +.eva-arr-status-text { + font-size: 2.2vh !important; + font-weight: 700 !important; + line-height: 1 !important; + white-space: nowrap !important; +} + +/* -------------------------------------------------------------------------- + SECTION CENTRALE : PROVENANCE & GARES DESSERVIES (VIAS) + -------------------------------------------------------------------------- */ +.eva-arr-col-center { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + padding: 0.6vh 1.8vw !important; + box-sizing: border-box !important; + overflow: hidden !important; + min-width: 0 !important; +} + +.eva-arr-origin-row { + display: flex !important; + align-items: center !important; + gap: 0.8vw !important; + overflow: hidden !important; + white-space: nowrap !important; + margin-bottom: 0.4vh !important; +} + +.eva-arr-origin-name { + font-size: 5.6vh !important; + font-weight: 900 !important; + color: #000c2f !important; + line-height: 1.1 !important; + letter-spacing: -0.02vw !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; +} + +/* Badge Motif de Perturbation (ex: Embouteillages) */ +.eva-arr-perturb-badge { + display: inline-flex !important; + align-items: center !important; + gap: 0.4vw !important; + background-color: #ffa74f !important; + color: #000c2f !important; + padding: 0.4vh 0.8vw !important; + border-radius: 6px !important; + height: 3.4vh !important; + box-sizing: border-box !important; + flex-shrink: 0 !important; +} + +.eva-arr-perturb-badge-icon { + width: 2.4vh !important; + height: 2.4vh !important; + flex-shrink: 0 !important; +} + +.eva-arr-perturb-badge-text { + font-size: 2.2vh !important; + font-weight: 700 !important; + line-height: 1 !important; + white-space: nowrap !important; +} + +/* Section Vias */ +.eva-arr-vias-container { + display: flex !important; + align-items: baseline !important; + white-space: nowrap !important; + overflow: hidden !important; + width: 100% !important; +} + +.eva-arr-vias-label { + font-size: 3.5vh !important; + font-weight: 700 !important; + color: #00c284 !important; /* Vert menthe SNCF */ + margin-right: 0.5vw !important; + flex-shrink: 0 !important; +} + +.eva-arr-vias-scroll-wrapper { + flex: 1 !important; + overflow: hidden !important; + white-space: nowrap !important; + position: relative !important; + display: flex !important; + align-items: baseline !important; +} + +.eva-arr-vias-inner { + display: inline-flex !important; + align-items: baseline !important; + white-space: nowrap !important; +} + +.eva-arr-vias-inner--animating { + animation-name: eva-arr-vias-marquee !important; + animation-timing-function: linear !important; + animation-iteration-count: infinite !important; + padding-left: 20% !important; +} + +@keyframes eva-arr-vias-marquee { + 0% { + transform: translateX(0%); + } + 100% { + transform: translateX(-100%); + } +} + +.eva-arr-vias-stop { + font-size: 3.5vh !important; + font-weight: 500 !important; + color: #2c3b57 !important; + line-height: 1 !important; +} + +.eva-arr-vias-bullet { + color: #00c284 !important; + font-weight: 800 !important; + margin: 0 0.5vw !important; + font-size: 2.8vh !important; + display: inline-block !important; + vertical-align: middle !important; +} + +/* -------------------------------------------------------------------------- + SECTION DROITE : MODE DE TRANSPORT / VOIE & OPERATEUR / NUMERO + -------------------------------------------------------------------------- */ +.eva-arr-col-right { + display: flex !important; + align-items: center !important; + height: 100% !important; + flex-shrink: 0 !important; +} + +/* Boîte Picto Autocar/Bus */ +.eva-arr-bus-box { + background-color: #000c2f !important; + border-radius: 8px !important; + width: 7.0vw !important; + height: 80% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin-right: 0.8vw !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important; +} + +.eva-arr-bus-icon { + width: 6.0vh !important; + height: 6.0vh !important; + color: #ffffff !important; +} + +/* Boîte Voie (si applicable pour train) */ +.eva-arr-voie-box { + background-color: #000c2f !important; + border-radius: 8px !important; + width: 7.0vw !important; + height: 80% !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + margin-right: 0.8vw !important; + color: #ffffff !important; +} + +.eva-arr-voie-label { + font-size: 1.8vh !important; + font-weight: 600 !important; + text-transform: uppercase !important; + opacity: 0.8 !important; +} + +.eva-arr-voie-val { + font-size: 4.5vh !important; + font-weight: 900 !important; + line-height: 1 !important; +} + +/* Bloc Opérateur & Numéro Vert Émeraude */ +.eva-arr-train-block { + background-color: #00d68f !important; + width: 10.0vw !important; + min-width: 10.0vw !important; + height: 100% !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + border-top-right-radius: 10px !important; + border-bottom-right-radius: 10px !important; + padding: 0 0.5vw !important; + box-sizing: border-box !important; +} + +.eva-arr-train-op { + font-size: 2.8vh !important; + font-weight: 700 !important; + color: #000c2f !important; + line-height: 1 !important; + margin-bottom: 0.3vh !important; + text-transform: uppercase !important; +} + +.eva-arr-train-num { + font-size: 4.2vh !important; + font-weight: 900 !important; + color: #000c2f !important; + line-height: 1 !important; + letter-spacing: 0.02vw !important; +} + +/* ========================================================================== + BANDEAU TRAVAUX / ALERTES (Pied de page) + ========================================================================== */ +.eva-arr-bottom-alert { + background-color: #fca758 !important; + border-radius: 10px !important; + display: flex !important; + align-items: center !important; + padding: 0.8vh 1.8vw !important; + margin-top: 1.0vh !important; + gap: 1.4vw !important; + box-sizing: border-box !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; + flex-shrink: 0 !important; + height: 10.8vh !important; + min-height: 10.8vh !important; + max-height: 10.8vh !important; +} + +.eva-arr-bottom-alert-icon-wrapper { + flex-shrink: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.eva-arr-bottom-alert-icon { + width: 5.8vh !important; + height: 5.8vh !important; + color: #000c2f !important; +} + +.eva-arr-bottom-alert-container { + flex: 1 !important; + overflow: hidden !important; + position: relative !important; + height: 9.0vh !important; + max-height: 9.0vh !important; + display: flex !important; + flex-direction: column !important; + justify-content: flex-start !important; +} + +.eva-arr-bottom-alert-content { + color: #000c2f !important; + font-size: 2.4vh !important; + font-weight: 600 !important; + line-height: 1.25 !important; + white-space: normal !important; + word-break: break-word !important; + will-change: transform, opacity !important; +} diff --git a/src/pages/afficheurs/eva/EVAArrivees.js b/src/pages/afficheurs/eva/EVAArrivees.js new file mode 100644 index 0000000..43ce099 --- /dev/null +++ b/src/pages/afficheurs/eva/EVAArrivees.js @@ -0,0 +1,1355 @@ +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import './EVAArrivees.css'; +import supabase from '../../../lib/supabase'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; +import MarqueeText from '../../../components/MarqueeText'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; + +// Helper utilitaire : format HH:MM avec deux points (ex: 22:39) +const formatTimeWithColon = (v) => { + if (v == null) return ''; + const tryDate = new Date(v); + if (!isNaN(tryDate.getTime())) { + const hh = String(tryDate.getHours()).padStart(2, '0'); + const mm = String(tryDate.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; + } + if (typeof v === 'string') { + const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/); + if (m) return `${String(m[1]).padStart(2, '0')}:${m[2]}`; + const m2 = v.match(/T(\d{2}):(\d{2})/); + if (m2) return `${m2[1]}:${m2[2]}`; + return v.slice(0, 5).replace('h', ':'); + } + return String(v).slice(0, 5).replace('h', ':'); +}; + +// Icône Passager avec valise à roulettes + Face avant de train (Logo En-tête EVA Arrivées) +const EVAArrivalsHeaderIcon = ({ size = '5.5vh', color = '#00d68f', style = {} }) => ( + + {/* Voyageur avec valise */} + + {/* Corps et jambes */} + + {/* Bras tirant la valise */} + + {/* Valise à roulettes */} + + + + + {/* Train de face */} + + {/* Pare-brise (découpé en bleu nuit) */} + + {/* Feux avant du train */} + + + {/* Grille / Ligne basse du train */} + + {/* Rails au sol */} + + + +); + +// Icône de Bus / Autocar SNCF +const EVABusIcon = ({ size = '6.0vh', color = '#ffffff', style = {} }) => ( + + + +); + +// Icône Information (Badge motif perturbation) +const EVAInfoIcon = ({ size = '2.4vh', color = '#000c2f', style = {} }) => ( + + + + + +); + +// Icône Information Grande (Bandeau Travaux inférieur) +const EVABigAlertIcon = ({ size = '5.8vh', color = '#000c2f', style = {} }) => ( + + {/* Bulle / Cercle avec pointe basse */} + + {/* Point 'i' */} + + {/* Barre 'i' */} + + {/* Ligne sous le bandeau */} + + +); + +// Icône Horloge pour les pastilles de statut +const EVAClockIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => ( + + + + +); + +// Icône Suppression (Croix) +const EVACrossIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => ( + + + + + +); + +// Défilement des gares de passage (Vias) +const EVAViasMarquee = ({ viaNames }) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [duration, setDuration] = useState(6); + + useEffect(() => { + const check = () => { + if (containerRef.current && textRef.current) { + const cWidth = containerRef.current.clientWidth; + const tWidth = textRef.current.scrollWidth; + if (tWidth > cWidth) { + setIsOverflowing(true); + setDuration(Math.max(2.0, tWidth / 240)); + } else { + setIsOverflowing(false); + } + } + }; + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, [viaNames]); + + if (!viaNames || viaNames.length === 0) { + return ( +
+ Direct +
+ ); + } + + return ( +
+ via +
+
+ {viaNames.map((v, i) => ( + + {i > 0 && •} + {v} + + ))} +
+
+
+ ); +}; + +// Helper de décodage des caractères, entités HTML et URL +const decodeText = (raw) => { + if (raw == null) return ''; + let str = String(raw); + + // 1. Décodage d'URL si présent (ex: %20, %C3%A9...) + if (str.includes('%')) { + try { str = decodeURIComponent(str); } catch (e) {} + } + + // 2. Traitement des séquences d'échappement retour à la ligne et apostrophes + str = str.replace(/\\r\\n|\\r|\\n/g, ' ').replace(/\\'/g, "'").replace(/\\"/g, '"'); + + // 3. Décodage des entités HTML avec DOMParser dans le navigateur + if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') { + try { + const doc = new DOMParser().parseFromString(str, 'text/html'); + str = doc.body.textContent || doc.body.innerText || str; + } catch (e) {} + } + + // 4. Décodage manuel de secours pour entités résiduelles + const htmlEntitiesMap = { + ' ': ' ', + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + '’': "’", + '‘': "‘", + ''': "'", + '«': '«', + '»': '»', + 'À': 'À', 'Â': 'Â', 'Ä': 'Ä', 'É': 'É', 'È': 'È', 'Ê': 'Ê', 'Ë': 'Ë', + 'Î': 'Î', 'Ï': 'Ï', 'Ô': 'Ô', 'Ö': 'Ö', 'Ù': 'Ù', 'Û': 'Û', 'Ü': 'Ü', + 'Ç': 'Ç', + 'à': 'à', 'â': 'â', 'ä': 'ä', 'é': 'é', 'è': 'è', 'ê': 'ê', 'ë': 'ë', + 'î': 'î', 'ï': 'ï', 'ô': 'ô', 'ö': 'ö', 'ù': 'ù', 'û': 'û', 'ü': 'ü', + 'ç': 'ç' + }; + str = str.replace(/&[a-zA-Z0-9#x]+;/g, (m) => htmlEntitiesMap[m] || m); + + // 5. Nettoyage des espaces consécutifs + return str.replace(/[ \t\r\n]+/g, ' ').trim(); +}; + +// Composant pour le bandeau d'alerte travaux / perturbations avec scroll vertical progressif +const EVABottomAlert = ({ message }) => { + const containerRef = useRef(null); + const contentRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [overflowDistance, setOverflowDistance] = useState(0); + const [animState, setAnimState] = useState('initial_pause'); + + const decodedText = decodeText(message); + + useEffect(() => { + const measure = () => { + if (containerRef.current && contentRef.current) { + const cHeight = containerRef.current.clientHeight; + const sHeight = contentRef.current.scrollHeight; + if (sHeight > cHeight + 2) { + setIsOverflowing(true); + setOverflowDistance(sHeight - cHeight); + } else { + setIsOverflowing(false); + setOverflowDistance(0); + } + } + }; + measure(); + const to = setTimeout(measure, 150); + window.addEventListener('resize', measure); + return () => { + clearTimeout(to); + window.removeEventListener('resize', measure); + }; + }, [decodedText]); + + useEffect(() => { + if (!isOverflowing || overflowDistance <= 0) { + setAnimState('initial_pause'); + return; + } + + let timer; + if (animState === 'initial_pause') { + // 5 secondes d'attente en haut avant de démarrer le défilement + timer = setTimeout(() => { + setAnimState('scrolling'); + }, 5000); + } else if (animState === 'scrolling') { + // Défilement progressif vers le bas pendant 10 secondes + timer = setTimeout(() => { + setAnimState('end_pause'); + }, 10000); + } else if (animState === 'end_pause') { + // 5 secondes d'attente en bas avant de recommencer + timer = setTimeout(() => { + setAnimState('resetting'); + }, 5000); + } else if (animState === 'resetting') { + // Réinitialisation fluide et retour en haut + timer = setTimeout(() => { + setAnimState('initial_pause'); + }, 300); + } + + return () => clearTimeout(timer); + }, [animState, isOverflowing, overflowDistance]); + + if (!decodedText) return null; + + let contentStyle = { + transform: 'translateY(0)', + transition: 'none', + opacity: 1 + }; + + if (isOverflowing && overflowDistance > 0) { + if (animState === 'initial_pause') { + contentStyle = { + transform: 'translateY(0)', + transition: 'none', + opacity: 1 + }; + } else if (animState === 'scrolling') { + contentStyle = { + transform: `translateY(-${overflowDistance}px)`, + transition: 'transform 10s linear', + opacity: 1 + }; + } else if (animState === 'end_pause') { + contentStyle = { + transform: `translateY(-${overflowDistance}px)`, + transition: 'none', + opacity: 1 + }; + } else if (animState === 'resetting') { + contentStyle = { + transform: 'translateY(0)', + transition: 'opacity 0.25s ease', + opacity: 0 + }; + } + } + + return ( +
+
+ +
+
+
+ {decodedText} +
+
+
+ ); +}; + +// Horloge temps réel EVA (HH:MM en blanc, SS en vert menthe encadrée) +function Clock() { + const [now, setNow] = useState(new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + const ss = String(now.getSeconds()).padStart(2, '0'); + + return ( +
+ {hh}:{mm} + {ss} +
+ ); +} + +export default function EVAArrivees() { + // Paramètres d'URL : ?gare=Nom-de-la-gare, ?gare_id=123 + const params = new URLSearchParams(window.location.search); + const gareParam = params.get('gare'); + const gareIdParam = params.get('gare_id') || params.get('gareId'); + const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + + // Support pour ?page=1, ?page=2 pour verrouiller l'affichage sur une page spécifique + const pageParam = params.get('page'); + const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10)) + ? Math.max(0, parseInt(pageParam, 10) - 1) + : null; + + // Mode debug via ?debug=1 + const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true'); + // eslint-disable-next-line no-unused-vars + const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]); + + // État heure courante + const [currentTime, setCurrentTime] = useState(new Date()); + useEffect(() => { + const id = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(id); + }, []); + + // Données Supabase + const [horaires, setHoraires] = useState([]); + const [allHoraires, setAllHoraires] = useState([]); + const [perturbations, setPerturbations] = useState([]); + // eslint-disable-next-line no-unused-vars + const [stationType, setStationType] = useState(null); + const [travaux, setTravaux] = useState([]); + const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); + const [, setLoading] = useState(false); + const [, setError] = useState(null); + + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId, 'EVAArrivees'); + useAutoAnnonce(horaires, currentTime, true); + + // Récupération type de gare + useEffect(() => { + let mounted = true; + const fetchStationType = async () => { + try { + let fetchedType = null; + let fetchedId = gareIdParam || null; + if (gareIdParam) { + const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1); + if (!gerr && gdata && gdata.length) { + fetchedType = gdata[0] && gdata[0].type; + fetchedId = gdata[0] && gdata[0].id; + } + } else if (gareDisplay) { + const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; + const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); + if (!gerr && gdata && gdata.length) { + const exactMatch = gdata.find(g => { + const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + return n1 === n2; + }); + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; + } + } + if (mounted) { + setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + setResolvedGareId(fetchedId); + } + } catch (e) { /* ignore */ } + }; + if (supabase) { + fetchStationType(); + } + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + + // Helpers pour perturbations + const normalizeName = useCallback((s) => { + if (!s) return ''; + try { + const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' '); + } catch (e) { + return String(s).toLowerCase().trim(); + } + }, []); + + const safeParseArrets = useCallback((p) => { + const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); + if (!raw) return []; + if (Array.isArray(raw)) return raw; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + if (Array.isArray(parsed.arrets)) return parsed.arrets; + return parsed; + } + return []; + } catch (e) { return []; } + }, []); + + const getStops = useCallback((h) => { + if (!h) return []; + if (Array.isArray(h.stops) && h.stops.length) return h.stops; + if (Array.isArray(h.gares_desservies) && h.gares_desservies.length) return h.gares_desservies; + if (Array.isArray(h.garesDesservies) && h.garesDesservies.length) return h.garesDesservies; + const candidates = ['stops', 'gares_desservies', 'garesDesservies', 'gares_desservies_json']; + for (const key of candidates) { + const raw = h[key]; + if (!raw) continue; + if (Array.isArray(raw)) return raw; + if (typeof raw === 'string') { + try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ } + } + } + return []; + }, []); + + const parsePerturbArretsObject = useCallback((p) => { + const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); + if (!raw) return { arrets: [], jours: null }; + if (typeof raw === 'object') { + if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null }; + if (Array.isArray(raw)) return { arrets: raw, jours: null }; + return { arrets: [], jours: null }; + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null }; + } + return { arrets: [], jours: null }; + } catch (e) { + return { arrets: [], jours: null }; + } + }, []); + + const getPerturbationJours = useCallback((p) => { + if (!p) return null; + const candidate = p.jours_perturbation || p.jours || null; + const result = new Set(); + const pushDate = (d) => { + if (!d) return; + try { + const dd = new Date(d); + if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0, 10)); + } catch (e) {} + }; + if (candidate) { + if (Array.isArray(candidate)) { + candidate.forEach(item => pushDate(item.date || item)); + } else if (typeof candidate === 'string') { + try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } + catch (e) { pushDate(candidate); } + } else if (typeof candidate === 'object') { + if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); + else pushDate(candidate.date || null); + } + } + try { + const parsedObj = parsePerturbArretsObject(p); + const jp = parsedObj.jours; + if (jp) { + if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); + else if (typeof jp === 'string') { + try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } + } else if (typeof jp === 'object') pushDate(jp.date || null); + } + } catch (e) {} + return result.size ? result : null; + }, [parsePerturbArretsObject]); + + const parseDelayMinutesFromArret = useCallback((arret) => { + if (!arret) return null; + const keys = ['retard_minutes', 'retard', 'delay_minutes', 'delay', 'retard_min']; + for (const k of keys) { + if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { + const n = Number(arret[k]); + if (!isNaN(n)) return Math.round(n); + } + } + return null; + }, []); + + const buildPerturbMap = useCallback((list = perturbations, horairesList = allHoraires) => { + const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] }; + (list || []).forEach(p => { + const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID; + if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); } + const number = p.number || p.train_number || p.train || p.numero || p.numero_train; + if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); } + const arrets = safeParseArrets(p); + if (Array.isArray(arrets) && arrets.length) { + arrets.forEach(a => { + const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code; + const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; + if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); } + if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); } + }); + } + if ((horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); } + }); + + if (Array.isArray(horairesList) && horairesList.length) { + try { + const byH = Object.keys(idx.byHoraire || {}); + if (byH && byH.length) { + for (const hid of byH) { + const h = horairesList.find(x => String(x.id || x.horaire_id || x.horaireId || x.numero) === String(hid)); + if (h) { + const num = h.numero_train || h.number || h.numero || null; + if (num != null) { + const numKey = String(num); + idx.byNumber[numKey] = idx.byNumber[numKey] || []; + (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); + } + } + } + } + } catch (e) {} + } + return idx; + }, [perturbations, allHoraires, safeParseArrets, normalizeName]); + + const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { + const map = idx || buildPerturbMap(); + const allPerturbs = []; + const hId = horaire && (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train); + const number = horaire && (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number); + + if (map) { + const addedIds = new Set(); + const addP = (p) => { + if (p && !addedIds.has(p.id)) { + addedIds.add(p.id); + allPerturbs.push(p); + } + }; + if (hId != null && map.byHoraire && map.byHoraire[String(hId)]) { + map.byHoraire[String(hId)].forEach(addP); + } + if (number != null && map.byNumber && map.byNumber[String(number)]) { + map.byNumber[String(number)].forEach(addP); + } + if (stationId != null && map.byStopId && map.byStopId[String(stationId)]) { + map.byStopId[String(stationId)].forEach(x => addP(x.perturbation)); + } + if (stationName && map.byStopName) { + const key = normalizeName(stationName); + if (map.byStopName[key]) map.byStopName[key].forEach(x => addP(x.perturbation)); + for (const existingKey of Object.keys(map.byStopName)) { + if (existingKey.includes(key) || key.includes(existingKey)) { + map.byStopName[existingKey].forEach(x => addP(x.perturbation)); + } + } + } + const trainStops = getStops(horaire); + if (Array.isArray(trainStops)) { + trainStops.forEach(s => { + const sId = s.gare_id || s.gareId || s.id || s.code; + const sName = s.gare_nom || s.nom || s.name || s.stop_name; + if (sId != null && map.byStopId && map.byStopId[String(sId)]) { + map.byStopId[String(sId)].forEach(x => addP(x.perturbation)); + } + if (sName && map.byStopName) { + const skey = normalizeName(sName); + if (map.byStopName[skey]) map.byStopName[skey].forEach(x => addP(x.perturbation)); + } + }); + } + if (map.global) { + map.global.forEach(addP); + } + } + + const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); }; + let horaireYmd = toYMD(horaire && (horaire._date || horaire.date || horaire.date_service || horaire.service_date)); + if (!horaireYmd && serviceDate) horaireYmd = serviceDate; + + let maxDelay = null; + let isSuppressed = false; + let causeDetails = ''; + + for (const p of allPerturbs) { + try { + if (horaireYmd) { + const pDate = toYMD(p.date || p.date_service); + const db = toYMD(p.date_debut || p.start_date); + const df = toYMD(p.date_fin || p.end_date); + if (pDate && pDate !== horaireYmd) continue; + if (db && df && !(horaireYmd >= db && horaireYmd <= df)) continue; + } + } catch (e) {} + try { + const joursSet = getPerturbationJours(p); + if (joursSet && horaireYmd && !joursSet.has(horaireYmd)) continue; + } catch (e) {} + + const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero; + const numberRef = p.number || p.train_number || p.numero || p.numero_train; + const appliesByRef = ref && (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero)); + const appliesByNumber = numberRef && (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number)); + + const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; + const arrets = parsedObj.arrets || []; + + const trainStops = getStops(horaire); + let trainStopMatch = false; + + const matchesTarget = (a) => { + if (!stationName && stationId == null) return false; + const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code; + const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; + if (stationId != null && String(aId) === String(stationId)) return true; + if (stationName && aName) { + const na = normalizeName(aName); + const ns = normalizeName(stationName); + return na === ns; + } + return false; + }; + + for (const a of arrets) { + const aId = a.gare_id || a.gareId || a.id || a.uic || a.code; + const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; + const na = normalizeName(aName || ''); + + const isTarget = matchesTarget(a); + let isTrainStop = false; + if (trainStops && Array.isArray(trainStops)) { + isTrainStop = trainStops.some(s => { + const sId = s.gare_id || s.gareId || s.id || s.code; + const sName = s.gare_nom || s.nom || s.name || s.stop_name; + if (sId != null && aId != null && String(sId) === String(aId)) return true; + if (sName && aName) { + const ns = normalizeName(sName); + return ns === na; + } + return false; + }); + } + const originName = normalizeName(horaire.gare_depart_nom || horaire.gare_depart || ''); + const destName = normalizeName(horaire.gare_arrivee_nom || horaire.gare_arrivee || ''); + if (originName === na || destName === na) isTrainStop = true; + + if (isTrainStop) trainStopMatch = true; + + if (isTarget) { + const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); + if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) { + isSuppressed = true; + } + } + } + + const isGlobal = (arrets.length === 0) && !ref && !numberRef; + const applies = appliesByRef || appliesByNumber || trainStopMatch || isGlobal; + + if (applies) { + const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); + if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) { + isSuppressed = true; + } + + for (const a of arrets) { + const tType = (a.type_perturbation || a.type || '').toString().toLowerCase(); + if (tType.includes('retard') || tType.includes('delay') || /retard/.test(tType)) { + const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; + if (maxDelay == null || d > maxDelay) maxDelay = d; + } + } + if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { + const d = parseDelayMinutesFromArret(p) || 0; + if (maxDelay == null || d > maxDelay) maxDelay = d; + } + + // Extraction du motif / cause de la perturbation + const cause = p.cause_details || p.motif || p.cause || p.description || p.information || p.details || ''; + if (cause) { + causeDetails = cause; + } + } + } + + if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; + if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; + return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; + }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); + + const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { + const map = buildPerturbMap(perturbList || []); + const targetStationId = displayStationId || gareIdParam || null; + const targetStationName = displayStationName || gareDisplay || null; + + return (list || []).map(h => { + const horaire = { ...h }; + const stops = getStops(horaire); + const stopsStatus = []; + for (let i = 0; i < stops.length; i++) { + const s = stops[i] || {}; + const stationId = s.gare_id || s.gareId || s.id || s.station_id || s.stationId || s.uic || s.code; + const stationName = s.gare_nom || s.nom || s.name || s.station_name || s.station_nom; + const st = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate); + stopsStatus.push({ stationId, stationName, status: st }); + } + + const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); + let displayTime = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || ''); + horaire._stopsStatus = stopsStatus; + + const stopForStation = (stops || []).find(s => { + const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; + const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; + const ns = normalizeName(nm || ''); + const target = normalizeName(targetStationName || ''); + return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target); + }) || null; + + horaire._status = displayStatus; + horaire._displayTime = displayTime; + const platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || ''; + horaire._displayPlatform = String(platformVal).trim(); + return horaire; + }); + }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); + + // Récupération Supabase avec polling + useEffect(() => { + let mounted = true; + let inFlight = false; + + const fetchData = async () => { + if (!mounted || inFlight) return; + inFlight = true; + if (!supabase) { + setLoading(false); + inFlight = false; + return; + } + setLoading(true); + setError(null); + try { + const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }, { data: travauxSubstData }] = await Promise.all([ + fetchAllRows(supabase.from('horaires').select('*')), + supabase.from('perturbations').select('*'), + supabase.from('travaux').select('*'), + supabase.from('travaux_substitutions').select('*') + ]); + + if (!mounted) { inFlight = false; return; } + if (hError || pError || tError) { + if (mounted) setError((hError || pError || tError).message || String(hError || pError || tError)); + setLoading(false); + inFlight = false; + return; + } + + let parsedHoraires = (horairesData || []).map(h => h || {}); + parsedHoraires = parsedHoraires.map(h => { + try { + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); + if (Array.isArray(parsed)) return { ...h, gares_desservies: parsed }; + } + } catch (e) {} + return h; + }); + + if (mounted) setAllHoraires(parsedHoraires || []); + + let displayHoraires = parsedHoraires.slice(); + if (gareDisplay) { + const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' ')); + displayHoraires = displayHoraires.filter(h => { + const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); + if (departName === normalized) return false; + + const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); + if (arriveeName === normalized) return true; + + const stopsList = getStops(h); + if (Array.isArray(stopsList) && stopsList.length) { + for (const s of stopsList) { + const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); + if (!stopName) continue; + const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0)); + if (isOrigin) continue; + if (stopName === normalized) return true; + } + } + return false; + }); + } else if (gareIdParam) { + const gid = String(gareIdParam); + displayHoraires = displayHoraires.filter(h => { + const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; + if (departId != null && String(departId) === gid) return false; + + const destId = (h.gare_arrivee_id || h.gare_arrivee || null); + if (destId != null && String(destId) === gid) return true; + + const stopsList = getStops(h); + if (Array.isArray(stopsList) && stopsList.length) { + for (const s of stopsList) { + const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; + if (stopId == null) continue; + const isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0)); + if (isOrigin) continue; + if (String(stopId) === gid) return true; + } + } + return false; + }); + } + + const dateKeys = ['date_depart', 'date', 'jour', 'service_date', 'date_service', 'date_dep']; + const normalizeDate = (o) => { + for (const k of dateKeys) { + if (!o) continue; + const v = o[k]; + if (!v) continue; + const d = new Date(v); + if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10); + } + return null; + }; + + const withDates = displayHoraires.map(h => ({ ...h, _date: normalizeDate(h) })); + + const parseTimeHM = (s) => { + if (!s) return null; + if (typeof s === 'number') return s; + const m = String(s).match(/(\d{1,2}):(\d{2})/); + if (m) return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); + const d = new Date(s); + if (!isNaN(d.getTime())) return d.getHours() * 60 + d.getMinutes(); + return null; + }; + + const now = new Date(); + const todayHM = now.getHours() * 60 + now.getMinutes(); + const enrich = withDates.map(h => { + let raw = h.heure_arrivee || h.time || h.heure || ''; + const stopsListForH = getStops(h); + if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) { + const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null; + const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; + const stop = byId || byName; + if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure; + } + const depHM = parseTimeHM(raw); + const displayTime = formatTimeWithColon(raw); + return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; + }); + + const pad = (n) => String(n).padStart(2, '0'); + const localDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const today = new Date(); + const todayStr = localDateStr(today); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + const tomorrowStr = localDateStr(tomorrow); + + const circulatesOn = (h, d) => { + const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const customDaysRaw = h.jours_personnalises; + if (!customDaysRaw) return false; + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) { return false; } + } + if (Array.isArray(customDays)) { + const match = customDays.find(item => item.date === targetDateStr); + if (match) return Boolean(match.circule); + } + return false; + }; + + const travauxList = travauxData || []; + const travauxSubstList = travauxSubstData || []; + + const parseDateOnly = (v) => { + if (!v) return null; + const d = new Date(v); + if (isNaN(d.getTime())) return null; + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + }; + + const parseJoursTravaux = (val) => { + if (val == null) return null; + if (Array.isArray(val)) return new Set(val.map(x => String(x).toLowerCase())); + const s = String(val || ''); + const parts = s.split(/[,;\s]+/).map(p => p.trim()).filter(Boolean); + return parts.length ? new Set(parts.map(p => p.toLowerCase())) : null; + }; + + const weekdayShort = (i) => { + switch (i) { case 0: return 'dim'; case 1: return 'lun'; case 2: return 'mar'; case 3: return 'mer'; case 4: return 'jeu'; case 5: return 'ven'; case 6: return 'sam'; default: return null; } + }; + + const travauxApplies = (t, h, date) => { + if (!t) return false; + const db = parseDateOnly(t.date_debut || t.dateDebut || t.start_date); + const df = parseDateOnly(t.date_fin || t.dateFin || t.end_date); + if (!db || !df) return false; + const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + if (d < db || d > df) return false; + const joursSet = parseJoursTravaux(t.jours_travaux || t.jours || t.days); + if (joursSet) { + const name = weekdayShort(d.getDay()); + if (!name) return false; + let ok = false; + for (const v of joursSet) { if (v.includes(name) || name.includes(v)) { ok = true; break; } } + if (!ok) return false; + } + const startHM = parseTimeHM(t.heure_debut || t.heureDebut || t.start_time); + const endHM = parseTimeHM(t.heure_fin || t.heureFin || t.end_time); + if (startHM == null || endHM == null) return true; + if (typeof h._depHM === 'number') return h._depHM >= startHM && h._depHM <= endHM; + return false; + }; + + const isSubstitution = (h) => { + if (!h) return false; + return travauxSubstList.some(ts => String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero)); + }; + + const enriched = enrich.map(h => { + let linked = false; + let appliesToday = false; + let appliesTomorrow = false; + for (const t of travauxList) { + const tLine = t.ligne_id || t.ligne || t.line_id || t.line || t.ligneId || t.lineId; + const hLine = h.ligne_id || h.ligne || h.line_id || h.line || h.ligneId || h.lineId; + + if (typeof tLine !== 'undefined' && tLine !== null) { + if (typeof hLine === 'undefined' || hLine === null) continue; + if (String(tLine) !== String(hLine)) continue; + } + + const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero; + if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true; + + const substForThisTravail = travauxSubstList.some(ts => String(ts.travaux_id) === String(t.id) && (String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero))); + if (substForThisTravail) linked = true; + + if (!appliesToday && travauxApplies(t, h, today)) appliesToday = true; + if (!appliesTomorrow && travauxApplies(t, h, tomorrow)) appliesTomorrow = true; + if (linked && appliesToday && appliesTomorrow) break; + } + return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow }; + }); + + const todayCandidates = enriched.filter(h => { + if (isSubstitution(h)) { + return Boolean(h._linkedToTravaux && h._travauxAppliesToday); + } + if (h._date && h._date !== todayStr) return false; + if (!circulatesOn(h, today)) return false; + if (typeof h._depHM !== 'number') return false; + if (h._depHM < todayHM - 1) return false; + if (h._travauxAppliesToday) return false; + return true; + }); + + const tomorrowCandidates = enriched.filter(h => { + if (isSubstitution(h)) { + return Boolean(h._linkedToTravaux && h._travauxAppliesTomorrow); + } + if (h._date && h._date !== tomorrowStr) return false; + if (!circulatesOn(h, tomorrow)) return false; + if (h._travauxAppliesTomorrow) return false; + return true; + }); + + let finalList; + let serviceDateStr; + if (todayCandidates && todayCandidates.length > 0) { + finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); + serviceDateStr = todayStr; + } else { + finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); + serviceDateStr = tomorrowStr; + } + + const filteredPerturb = (perturbData || []).filter(p => { + try { + const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); }; + const pDate = toYMD(p.date || p.date_service); + const db = toYMD(p.date_debut || p.start_date); + const df = toYMD(p.date_fin || p.end_date); + if (pDate && pDate !== serviceDateStr) return false; + if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; + try { + const joursSet = getPerturbationJours(p); + if (joursSet && !joursSet.has(serviceDateStr)) return false; + } catch (e) {} + return true; + } catch (e) { + return true; + } + }); + + const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr); + + setHoraires(annotated || []); + setPerturbations(filteredPerturb || []); + setTravaux(travauxData || []); + setTravauxSubstitutions(travauxSubstData || []); + } catch (err) { + console.error('Erreur Supabase EVAArrivees', err); + if (mounted) setError(err.message || String(err)); + } finally { + if (mounted) setLoading(false); + inFlight = false; + } + }; + + fetchData(); + const intervalId = setInterval(fetchData, 15000); + return () => { mounted = false; clearInterval(intervalId); }; + }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]); + + // Alternance retard / texte toutes les 2s + const [alternateDelay, setAlternateDelay] = useState(false); + useEffect(() => { + const delayTimer = setInterval(() => { + setAlternateDelay(prev => !prev); + }, 2000); + return () => clearInterval(delayTimer); + }, []); + + // Pagination : 4 trains par page sur les écrans EVA + const ITEMS_PER_PAGE = 4; + const totalPages = Math.max(1, Math.ceil(horaires.length / ITEMS_PER_PAGE)); + const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0); + + useEffect(() => { + if (lockedPageIndex !== null) { + setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); + return; + } + const pageInterval = setInterval(() => { + setPageIndex((prev) => { + if (totalPages <= 1) return 0; + return (prev + 1) % totalPages; + }); + }, 10000); // 10 secondes par page + return () => clearInterval(pageInterval); + }, [lockedPageIndex, totalPages]); + + const displayedHoraires = horaires.slice(pageIndex * ITEMS_PER_PAGE, (pageIndex + 1) * ITEMS_PER_PAGE); + + // Recherche d'un message de travaux ou d'alerte pour le bandeau bas + const activeTravauxMessage = (() => { + // Chercher un message explicite dans travaux + if (Array.isArray(travaux) && travaux.length > 0) { + const activeT = travaux.find(t => { + const txt = t.message || t.description || t.texte || t.motif || t.details || t.titre || t.commentaire || ''; + return Boolean(txt && txt.trim().length > 0); + }); + if (activeT) { + return activeT.message || activeT.description || activeT.texte || activeT.motif || activeT.details || activeT.titre || activeT.commentaire; + } + } + // Chercher dans perturbations générales + if (Array.isArray(perturbations) && perturbations.length > 0) { + const activeP = perturbations.find(p => { + const txt = p.message || p.description || p.texte || p.cause_details || ''; + return Boolean(txt && txt.trim().length > 0); + }); + if (activeP) { + return activeP.message || activeP.description || activeP.texte || activeP.cause_details; + } + } + return null; + })(); + + return ( +
+ {/* HEADER */} +
+
+ +
+ Arrivées + / + Arrivals From / Ankünfte Aus +
+
+ +
+ +
+
+ + {/* LISTE DES TRAINS (Cartes) */} +
    + {displayedHoraires.length === 0 ? ( +
  • +
    + Aucune arrivée prévue pour le moment. +
    +
  • + ) : ( + displayedHoraires.map((h, idx) => { + const horaire = h || {}; + const op = (horaire.type_train || horaire.op || horaire.type || 'TER').toUpperCase(); + const number = horaire.numero_train || horaire.number || horaire.numero || ''; + + const rawPlatform = ( + horaire._displayPlatform || + horaire.quai_reel || + horaire.quai || + horaire.quai_prevu || + horaire.platform || + horaire.voie || + horaire.track || + '' + ).toString().trim(); + + const isCG = rawPlatform.toUpperCase() === 'CG' || + rawPlatform.toUpperCase().includes('CG') || + String(horaire.quai || '').toUpperCase().includes('CG') || + String(horaire.quai_reel || '').toUpperCase().includes('CG') || + String(horaire.quai_prevu || '').toUpperCase().includes('CG') || + String(horaire.platform || '').toUpperCase().includes('CG'); + + const isCarTER = isCG || + op.includes('CAR') || + op.includes('AUTOCAR') || + op.includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('BUS') || + String(horaire.type_train || '').toUpperCase().includes('CAR') || + String(horaire.type_train || '').toUpperCase().includes('BUS') || + String(horaire.libelle_type_train || '').toUpperCase().includes('CAR') || + String(horaire.libelle_type_train || '').toUpperCase().includes('BUS') || + (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero)); + + const time = horaire._displayTime || formatTimeWithColon(horaire.heure_arrivee || horaire.time || horaire.heure || ''); + const origin = horaire.gare_depart_nom || horaire.gare_depart || 'Origine inconnue'; + + const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); + const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); + const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + + const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé'); + const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard'); + + let delayVal = '+10 min'; + if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { + delayVal = `+${status.delayMinutes} min`; + } else if (status?.delayMinutes) { + delayVal = `+${status.delayMinutes} min`; + } else if (status?.label && status.label.includes('+')) { + const m = status.label.match(/\+(\d+)\s*(?:min)?/); + if (m) delayVal = `+${m[1]} min`; + else delayVal = status.label; + } + + let pillText = isSuppressed ? 'supprimé' : isDelayed ? (alternateDelay ? 'retardé' : delayVal) : 'à l\'heure'; + const pillModifier = isSuppressed ? 'suppressed' : isDelayed ? 'delayed' : 'ontime'; + + // Motif de perturbation (ex: Embouteillages, Travaux, etc.) + const perturbationReason = decodeText(status?.causeDetails || (isDelayed && !status?.causeDetails ? 'Retard' : '')); + + // Calcul des gares intermédiaires (Vias) + const stopsList = getStops(horaire); + const viaNames = []; + if (Array.isArray(stopsList)) { + for (const s of stopsList) { + const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; + if (!sName) continue; + const norm = normalizeName(sName); + if (norm === normalizeName(origin || '')) continue; + if (norm === normalizeName(stationName || '')) break; + viaNames.push(decodeText(sName)); + } + } + + const decodedOrigin = decodeText(origin); + + return ( +
  • + {/* COLONNE GAUCHE : HEURE & PASTILLE DE STATUT */} +
    +
    {time}
    +
    + {isSuppressed ? ( + + ) : ( + + )} + {pillText} +
    +
    + + {/* SECTION CENTRALE : PROVENANCE & MOTIF PERTURBATION & VIAS */} +
    +
    + + {perturbationReason && ( +
    + + {perturbationReason} +
    + )} +
    + +
    + + {/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */} +
    + {isCarTER ? ( +
    + +
    + ) : (rawPlatform && !isCG) ? ( +
    + Voie + {rawPlatform} +
    + ) : null} + +
    + {op} + {number} +
    +
    +
  • + ); + }) + )} +
+ + {/* BANDEAU BAS : INFORMATION TRAVAUX / PERTURBATION AVEC SCROLL PROGRESSIF */} + {activeTravauxMessage && ( + + )} +
+ ); +} diff --git a/src/pages/afficheurs/eva/EVADeparts.css b/src/pages/afficheurs/eva/EVADeparts.css new file mode 100644 index 0000000..400e961 --- /dev/null +++ b/src/pages/afficheurs/eva/EVADeparts.css @@ -0,0 +1,476 @@ +/* ========================================================================== + EVA DEPARTS (AFFICHEUR VOYAGEURS EVA) - CSS + ========================================================================== */ + +/* Container Principal */ +.eva-dep-container { + font-family: 'Avenir', 'Achemine', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important; + height: 100vh !important; + width: 100vw !important; + display: flex !important; + flex-direction: column !important; + background-color: #000824 !important; /* Bleu nuit très profond EVA */ + overflow: hidden !important; + color: #ffffff !important; + margin: 0 !important; + padding: 1.2vh 1.8vw !important; + box-sizing: border-box !important; + user-select: none !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; +} + +/* ========================================================================== + HEADER (En-tête EVA Départs) + ========================================================================== */ +.eva-dep-header { + height: 9.5vh !important; + min-height: 9.5vh !important; + max-height: 9.5vh !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0 0.5vw !important; + margin-bottom: 1.0vh !important; + box-sizing: border-box !important; +} + +.eva-dep-header-left { + display: flex !important; + align-items: center !important; + gap: 1.0vw !important; +} + +.eva-dep-header-icon { + width: 5.5vh !important; + height: 5.5vh !important; + flex-shrink: 0 !important; + color: #03c5ff !important; /* Bleu cyan/azur vif EVA Départs (#03c5ff) */ +} + +.eva-dep-header-titles { + display: flex !important; + align-items: baseline !important; + gap: 0.6vw !important; + white-space: nowrap !important; +} + +.eva-dep-header-title-main { + font-size: 5.2vh !important; + font-weight: 900 !important; + color: #ffffff !important; + letter-spacing: 0.04vw !important; + line-height: 1 !important; + text-transform: uppercase !important; +} + +.eva-dep-header-slash { + font-size: 3.8vh !important; + font-weight: 700 !important; + color: #03c5ff !important; + line-height: 1 !important; +} + +.eva-dep-header-title-sub { + font-size: 3.2vh !important; + font-weight: 700 !important; + color: #03c5ff !important; + letter-spacing: 0.02vw !important; + line-height: 1 !important; + text-transform: uppercase !important; +} + +.eva-dep-header-right { + display: flex !important; + align-items: center !important; +} + +/* Horloge EVA Départs */ +.eva-dep-clock-box { + border: 2.2px solid #03c5ff !important; + background-color: #000824 !important; + border-radius: 8px !important; + padding: 0.5vh 1.0vw !important; + display: flex !important; + align-items: baseline !important; + gap: 0.5vw !important; + box-shadow: 0 0 12px rgba(3, 197, 255, 0.25) !important; +} + +.eva-dep-clock-main { + font-size: 4.8vh !important; + font-weight: 800 !important; + color: #ffffff !important; + line-height: 1 !important; + letter-spacing: 0.02vw !important; +} + +.eva-dep-clock-seconds { + font-size: 3.8vh !important; + font-weight: 800 !important; + color: #03c5ff !important; + line-height: 1 !important; +} + +/* ========================================================================== + LISTE DES HORAIRES (Cartes Départs) + ========================================================================== */ +.eva-dep-list { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: flex-start !important; + gap: 1.2vh !important; + padding: 0 !important; + margin: 0 !important; + list-style: none !important; + overflow: hidden !important; +} + +.eva-dep-row { + background-color: #e9eef6 !important; + border-radius: 10px !important; + display: flex !important; + align-items: stretch !important; + height: calc((100% - 3.6vh) / 4) !important; + min-height: 12vh !important; + box-sizing: border-box !important; + overflow: hidden !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25) !important; +} + +/* -------------------------------------------------------------------------- + COLONNE GAUCHE : HEURE & STATUT + -------------------------------------------------------------------------- */ +.eva-dep-col-time { + width: 14.5vw !important; + min-width: 14.5vw !important; + max-width: 14.5vw !important; + background-color: #d7e1f0 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; + padding: 0.8vh 0.6vw !important; + box-sizing: border-box !important; + border-top-left-radius: 10px !important; + border-bottom-left-radius: 10px !important; +} + +.eva-dep-time-value { + font-size: 5.6vh !important; + font-weight: 900 !important; + color: #000c2f !important; + line-height: 1 !important; + margin-bottom: 0.6vh !important; + letter-spacing: -0.02vw !important; +} + +/* Pastilles de statut */ +.eva-dep-status-pill { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 0.4vw !important; + padding: 0.3vh 0.9vw !important; + border-radius: 20px !important; + box-sizing: border-box !important; + height: 3.8vh !important; +} + +.eva-dep-status-pill--ontime { + background-color: #d7e1f0 !important; + border: 1.8px solid #7d95b5 !important; + color: #1a2b4c !important; +} + +.eva-dep-status-pill--delayed { + background-color: #ffa74f !important; + color: #000c2f !important; +} + +.eva-dep-status-pill--suppressed { + background-color: #ff5c73 !important; + color: #ffffff !important; +} + +.eva-dep-status-icon { + width: 2.5vh !important; + height: 2.5vh !important; + flex-shrink: 0 !important; +} + +.eva-dep-status-text { + font-size: 2.2vh !important; + font-weight: 700 !important; + line-height: 1 !important; + white-space: nowrap !important; +} + +/* -------------------------------------------------------------------------- + SECTION CENTRALE : DESTINATION & GARES DESSERVIES (VIAS) + -------------------------------------------------------------------------- */ +.eva-dep-col-center { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + padding: 0.6vh 1.8vw !important; + box-sizing: border-box !important; + overflow: hidden !important; + min-width: 0 !important; +} + +.eva-dep-origin-row { + display: flex !important; + align-items: center !important; + gap: 0.8vw !important; + overflow: hidden !important; + white-space: nowrap !important; + margin-bottom: 0.4vh !important; +} + +.eva-dep-origin-name { + font-size: 5.6vh !important; + font-weight: 900 !important; + color: #000c2f !important; + line-height: 1.1 !important; + letter-spacing: -0.02vw !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; +} + +/* Badge Motif de Perturbation (ex: Travaux, Panne) */ +.eva-dep-perturb-badge { + display: inline-flex !important; + align-items: center !important; + gap: 0.4vw !important; + background-color: #ffa74f !important; + color: #000c2f !important; + padding: 0.4vh 0.8vw !important; + border-radius: 6px !important; + height: 3.4vh !important; + box-sizing: border-box !important; + flex-shrink: 0 !important; +} + +.eva-dep-perturb-badge-icon { + width: 2.4vh !important; + height: 2.4vh !important; + flex-shrink: 0 !important; +} + +.eva-dep-perturb-badge-text { + font-size: 2.2vh !important; + font-weight: 700 !important; + line-height: 1 !important; + white-space: nowrap !important; +} + +/* Section Vias */ +.eva-dep-vias-container { + display: flex !important; + align-items: baseline !important; + white-space: nowrap !important; + overflow: hidden !important; + width: 100% !important; +} + +.eva-dep-vias-label { + font-size: 3.5vh !important; + font-weight: 700 !important; + color: #03c5ff !important; /* Bleu azur EVA Départs (#03c5ff) */ + margin-right: 0.5vw !important; + flex-shrink: 0 !important; +} + +.eva-dep-vias-scroll-wrapper { + flex: 1 !important; + overflow: hidden !important; + white-space: nowrap !important; + position: relative !important; + display: flex !important; + align-items: baseline !important; +} + +.eva-dep-vias-inner { + display: inline-flex !important; + align-items: baseline !important; + white-space: nowrap !important; +} + +.eva-dep-vias-inner--animating { + animation-name: eva-dep-vias-marquee !important; + animation-timing-function: linear !important; + animation-iteration-count: infinite !important; + padding-left: 20% !important; +} + +@keyframes eva-dep-vias-marquee { + 0% { + transform: translateX(0%); + } + 100% { + transform: translateX(-100%); + } +} + +.eva-dep-vias-stop { + font-size: 3.5vh !important; + font-weight: 500 !important; + color: #2c3b57 !important; + line-height: 1 !important; +} + +.eva-dep-vias-bullet { + color: #03c5ff !important; + font-weight: 800 !important; + margin: 0 0.5vw !important; + font-size: 2.8vh !important; + display: inline-block !important; + vertical-align: middle !important; +} + +/* -------------------------------------------------------------------------- + SECTION DROITE : MODE DE TRANSPORT / VOIE & OPERATEUR / NUMERO + -------------------------------------------------------------------------- */ +.eva-dep-col-right { + display: flex !important; + align-items: center !important; + height: 100% !important; + flex-shrink: 0 !important; +} + +/* Boîte Picto Autocar/Bus */ +.eva-dep-bus-box { + background-color: #000c2f !important; + border-radius: 8px !important; + width: 7.0vw !important; + height: 80% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin-right: 0.8vw !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important; +} + +.eva-dep-bus-icon { + width: 6.0vh !important; + height: 6.0vh !important; + color: #ffffff !important; +} + +/* Boîte Voie (si applicable pour train) */ +.eva-dep-voie-box { + background-color: #000c2f !important; + border-radius: 8px !important; + width: 7.0vw !important; + height: 80% !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + margin-right: 0.8vw !important; + color: #ffffff !important; +} + +.eva-dep-voie-label { + font-size: 1.8vh !important; + font-weight: 600 !important; + text-transform: uppercase !important; + opacity: 0.8 !important; +} + +.eva-dep-voie-val { + font-size: 4.5vh !important; + font-weight: 900 !important; + line-height: 1 !important; +} + +/* Bloc Opérateur & Numéro Bleu Azur Départs */ +.eva-dep-train-block { + background-color: #03c5ff !important; /* Bleu cyan/azur #03c5ff */ + width: 10.0vw !important; + min-width: 10.0vw !important; + height: 100% !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + border-top-right-radius: 10px !important; + border-bottom-right-radius: 10px !important; + padding: 0 0.5vw !important; + box-sizing: border-box !important; +} + +.eva-dep-train-op { + font-size: 2.8vh !important; + font-weight: 700 !important; + color: #000c2f !important; + line-height: 1 !important; + margin-bottom: 0.3vh !important; + text-transform: uppercase !important; +} + +.eva-dep-train-num { + font-size: 4.2vh !important; + font-weight: 900 !important; + color: #000c2f !important; + line-height: 1 !important; + letter-spacing: 0.02vw !important; +} + +/* ========================================================================== + BANDEAU TRAVAUX / ALERTES (Pied de page) + ========================================================================== */ +.eva-dep-bottom-alert { + background-color: #fca758 !important; + border-radius: 10px !important; + display: flex !important; + align-items: center !important; + padding: 0.8vh 1.8vw !important; + margin-top: 1.0vh !important; + gap: 1.4vw !important; + box-sizing: border-box !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; + flex-shrink: 0 !important; + height: 10.8vh !important; + min-height: 10.8vh !important; + max-height: 10.8vh !important; +} + +.eva-dep-bottom-alert-icon-wrapper { + flex-shrink: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.eva-dep-bottom-alert-icon { + width: 5.8vh !important; + height: 5.8vh !important; + color: #000c2f !important; +} + +.eva-dep-bottom-alert-container { + flex: 1 !important; + overflow: hidden !important; + position: relative !important; + height: 9.0vh !important; + max-height: 9.0vh !important; + display: flex !important; + flex-direction: column !important; + justify-content: flex-start !important; +} + +.eva-dep-bottom-alert-content { + color: #000c2f !important; + font-size: 2.4vh !important; + font-weight: 600 !important; + line-height: 1.25 !important; + white-space: normal !important; + word-break: break-word !important; + will-change: transform, opacity !important; +} diff --git a/src/pages/afficheurs/eva/EVADeparts.js b/src/pages/afficheurs/eva/EVADeparts.js new file mode 100644 index 0000000..7f56138 --- /dev/null +++ b/src/pages/afficheurs/eva/EVADeparts.js @@ -0,0 +1,1373 @@ +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import './EVADeparts.css'; +import supabase from '../../../lib/supabase'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; +import MarqueeText from '../../../components/MarqueeText'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; + +// Helper utilitaire : format HH:MM avec deux points (ex: 22:39) +const formatTimeWithColon = (v) => { + if (v == null) return ''; + const tryDate = new Date(v); + if (!isNaN(tryDate.getTime())) { + const hh = String(tryDate.getHours()).padStart(2, '0'); + const mm = String(tryDate.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; + } + if (typeof v === 'string') { + const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/); + if (m) return `${String(m[1]).padStart(2, '0')}:${m[2]}`; + const m2 = v.match(/T(\d{2}):(\d{2})/); + if (m2) return `${m2[1]}:${m2[2]}`; + return v.slice(0, 5).replace('h', ':'); + } + return String(v).slice(0, 5).replace('h', ':'); +}; + +// Icône Passager + Face avant de train (Logo En-tête EVA Départs) +const EVADeparturesHeaderIcon = ({ size = '5.5vh', color = '#03c5ff', style = {} }) => ( + + {/* Voyageur avec valise */} + + {/* Corps et jambes */} + + {/* Bras tirant la valise */} + + {/* Valise à roulettes */} + + + + + {/* Train de face */} + + {/* Pare-brise (découpé en bleu nuit) */} + + {/* Feux avant du train */} + + + {/* Grille / Ligne basse du train */} + + {/* Rails au sol */} + + + +); + +// Icône de Bus / Autocar SNCF +const EVABusIcon = ({ size = '6.0vh', color = '#ffffff', style = {} }) => ( + + + +); + +// Icône Information (Badge motif perturbation) +const EVAInfoIcon = ({ size = '2.4vh', color = '#000c2f', style = {} }) => ( + + + + + +); + +// Icône Information Grande (Bandeau Travaux inférieur) +const EVABigAlertIcon = ({ size = '5.8vh', color = '#000c2f', style = {} }) => ( + + + + + + +); + +// Icône Horloge pour les pastilles de statut +const EVAClockIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => ( + + + + +); + +// Icône Suppression (Croix) +const EVACrossIcon = ({ size = '2.5vh', color = 'currentColor', style = {} }) => ( + + + + + +); + +// Défilement des gares de passage (Vias) +const EVAViasMarquee = ({ viaNames }) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [duration, setDuration] = useState(6); + + useEffect(() => { + const check = () => { + if (containerRef.current && textRef.current) { + const cWidth = containerRef.current.clientWidth; + const tWidth = textRef.current.scrollWidth; + if (tWidth > cWidth) { + setIsOverflowing(true); + setDuration(Math.max(2.0, tWidth / 240)); + } else { + setIsOverflowing(false); + } + } + }; + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, [viaNames]); + + if (!viaNames || viaNames.length === 0) { + return ( +
+ Direct +
+ ); + } + + return ( +
+ via +
+
+ {viaNames.map((v, i) => ( + + {i > 0 && •} + {v} + + ))} +
+
+
+ ); +}; + +// Helper de décodage des caractères, entités HTML et URL +const decodeText = (raw) => { + if (raw == null) return ''; + let str = String(raw); + + // 1. Décodage d'URL si présent (ex: %20, %C3%A9...) + if (str.includes('%')) { + try { str = decodeURIComponent(str); } catch (e) {} + } + + // 2. Traitement des séquences d'échappement retour à la ligne et apostrophes + str = str.replace(/\\r\\n|\\r|\\n/g, ' ').replace(/\\'/g, "'").replace(/\\"/g, '"'); + + // 3. Décodage des entités HTML avec DOMParser dans le navigateur + if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') { + try { + const doc = new DOMParser().parseFromString(str, 'text/html'); + str = doc.body.textContent || doc.body.innerText || str; + } catch (e) {} + } + + // 4. Décodage manuel de secours pour entités résiduelles + const htmlEntitiesMap = { + ' ': ' ', + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + '’': "’", + '‘': "‘", + ''': "'", + '«': '«', + '»': '»', + 'À': 'À', 'Â': 'Â', 'Ä': 'Ä', 'É': 'É', 'È': 'È', 'Ê': 'Ê', 'Ë': 'Ë', + 'Î': 'Î', 'Ï': 'Ï', 'Ô': 'Ô', 'Ö': 'Ö', 'Ù': 'Ù', 'Û': 'Û', 'Ü': 'Ü', + 'Ç': 'Ç', + 'à': 'à', 'â': 'â', 'ä': 'ä', 'é': 'é', 'è': 'è', 'ê': 'ê', 'ë': 'ë', + 'î': 'î', 'ï': 'ï', 'ô': 'ô', 'ö': 'ö', 'ù': 'ù', 'û': 'û', 'ü': 'ü', + 'ç': 'ç' + }; + str = str.replace(/&[a-zA-Z0-9#x]+;/g, (m) => htmlEntitiesMap[m] || m); + + // 5. Nettoyage des espaces consécutifs + return str.replace(/[ \t\r\n]+/g, ' ').trim(); +}; + +// Composant pour le bandeau d'alerte travaux / perturbations avec scroll vertical progressif +const EVABottomAlert = ({ message }) => { + const containerRef = useRef(null); + const contentRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [overflowDistance, setOverflowDistance] = useState(0); + const [animState, setAnimState] = useState('initial_pause'); + + const decodedText = decodeText(message); + + useEffect(() => { + const measure = () => { + if (containerRef.current && contentRef.current) { + const cHeight = containerRef.current.clientHeight; + const sHeight = contentRef.current.scrollHeight; + if (sHeight > cHeight + 2) { + setIsOverflowing(true); + setOverflowDistance(sHeight - cHeight); + } else { + setIsOverflowing(false); + setOverflowDistance(0); + } + } + }; + measure(); + const to = setTimeout(measure, 150); + window.addEventListener('resize', measure); + return () => { + clearTimeout(to); + window.removeEventListener('resize', measure); + }; + }, [decodedText]); + + useEffect(() => { + if (!isOverflowing || overflowDistance <= 0) { + setAnimState('initial_pause'); + return; + } + + let timer; + if (animState === 'initial_pause') { + // 5 secondes d'attente en haut avant de démarrer le défilement + timer = setTimeout(() => { + setAnimState('scrolling'); + }, 5000); + } else if (animState === 'scrolling') { + // Défilement progressif vers le bas pendant 10 secondes + timer = setTimeout(() => { + setAnimState('end_pause'); + }, 10000); + } else if (animState === 'end_pause') { + // 5 secondes d'attente en bas avant de recommencer + timer = setTimeout(() => { + setAnimState('resetting'); + }, 5000); + } else if (animState === 'resetting') { + // Réinitialisation fluide et retour en haut + timer = setTimeout(() => { + setAnimState('initial_pause'); + }, 300); + } + + return () => clearTimeout(timer); + }, [animState, isOverflowing, overflowDistance]); + + if (!decodedText) return null; + + let contentStyle = { + transform: 'translateY(0)', + transition: 'none', + opacity: 1 + }; + + if (isOverflowing && overflowDistance > 0) { + if (animState === 'initial_pause') { + contentStyle = { + transform: 'translateY(0)', + transition: 'none', + opacity: 1 + }; + } else if (animState === 'scrolling') { + contentStyle = { + transform: `translateY(-${overflowDistance}px)`, + transition: 'transform 10s linear', + opacity: 1 + }; + } else if (animState === 'end_pause') { + contentStyle = { + transform: `translateY(-${overflowDistance}px)`, + transition: 'none', + opacity: 1 + }; + } else if (animState === 'resetting') { + contentStyle = { + transform: 'translateY(0)', + transition: 'opacity 0.25s ease', + opacity: 0 + }; + } + } + + return ( +
+
+ +
+
+
+ {decodedText} +
+
+
+ ); +}; + +// Horloge temps réel EVA Départs (HH:MM en blanc, SS en bleu azur encadrée) +function Clock() { + const [now, setNow] = useState(new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + const ss = String(now.getSeconds()).padStart(2, '0'); + + return ( +
+ {hh}:{mm} + {ss} +
+ ); +} + +export default function EVADeparts() { + // Paramètres d'URL : ?gare=Nom-de-la-gare, ?gare_id=123 + const params = new URLSearchParams(window.location.search); + const gareParam = params.get('gare'); + const gareIdParam = params.get('gare_id') || params.get('gareId'); + const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + + // Support pour ?page=1, ?page=2 pour verrouiller l'affichage sur une page spécifique + const pageParam = params.get('page'); + const lockedPageIndex = pageParam !== null && !isNaN(parseInt(pageParam, 10)) + ? Math.max(0, parseInt(pageParam, 10) - 1) + : null; + + // Mode debug via ?debug=1 + const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true'); + // eslint-disable-next-line no-unused-vars + const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]); + + // État heure courante + const [currentTime, setCurrentTime] = useState(new Date()); + useEffect(() => { + const id = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(id); + }, []); + + // Données Supabase + const [horaires, setHoraires] = useState([]); + const [allHoraires, setAllHoraires] = useState([]); + const [perturbations, setPerturbations] = useState([]); + // eslint-disable-next-line no-unused-vars + const [stationType, setStationType] = useState(null); + const [travaux, setTravaux] = useState([]); + const [travauxSubstitutions, setTravauxSubstitutions] = useState([]); + const [, setLoading] = useState(false); + const [, setError] = useState(null); + + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId, 'EVADeparts'); + useAutoAnnonce(horaires, currentTime, false); + + // Récupération type de gare + useEffect(() => { + let mounted = true; + const fetchStationType = async () => { + try { + let fetchedType = null; + let fetchedId = gareIdParam || null; + if (gareIdParam) { + const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1); + if (!gerr && gdata && gdata.length) { + fetchedType = gdata[0] && gdata[0].type; + fetchedId = gdata[0] && gdata[0].id; + } + } else if (gareDisplay) { + const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; + const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); + if (!gerr && gdata && gdata.length) { + const exactMatch = gdata.find(g => { + const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + return n1 === n2; + }); + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; + } + } + if (mounted) { + setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + setResolvedGareId(fetchedId); + } + } catch (e) { /* ignore */ } + }; + if (supabase) { + fetchStationType(); + } + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + + // Helpers pour perturbations + const normalizeName = useCallback((s) => { + if (!s) return ''; + try { + const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' '); + } catch (e) { + return String(s).toLowerCase().trim(); + } + }, []); + + const safeParseArrets = useCallback((p) => { + const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); + if (!raw) return []; + if (Array.isArray(raw)) return raw; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + if (Array.isArray(parsed.arrets)) return parsed.arrets; + return parsed; + } + return []; + } catch (e) { return []; } + }, []); + + const getStops = useCallback((h) => { + if (!h) return []; + if (Array.isArray(h.stops) && h.stops.length) return h.stops; + if (Array.isArray(h.gares_desservies) && h.gares_desservies.length) return h.gares_desservies; + if (Array.isArray(h.garesDesservies) && h.garesDesservies.length) return h.garesDesservies; + const candidates = ['stops', 'gares_desservies', 'garesDesservies', 'gares_desservies_json']; + for (const key of candidates) { + const raw = h[key]; + if (!raw) continue; + if (Array.isArray(raw)) return raw; + if (typeof raw === 'string') { + try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ } + } + } + return []; + }, []); + + const parsePerturbArretsObject = useCallback((p) => { + const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); + if (!raw) return { arrets: [], jours: null }; + if (typeof raw === 'object') { + if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null }; + if (Array.isArray(raw)) return { arrets: raw, jours: null }; + return { arrets: [], jours: null }; + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null }; + } + return { arrets: [], jours: null }; + } catch (e) { + return { arrets: [], jours: null }; + } + }, []); + + const getPerturbationJours = useCallback((p) => { + if (!p) return null; + const candidate = p.jours_perturbation || p.jours || null; + const result = new Set(); + const pushDate = (d) => { + if (!d) return; + try { + const dd = new Date(d); + if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0, 10)); + } catch (e) {} + }; + if (candidate) { + if (Array.isArray(candidate)) { + candidate.forEach(item => pushDate(item.date || item)); + } else if (typeof candidate === 'string') { + try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } + catch (e) { pushDate(candidate); } + } else if (typeof candidate === 'object') { + if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); + else pushDate(candidate.date || null); + } + } + try { + const parsedObj = parsePerturbArretsObject(p); + const jp = parsedObj.jours; + if (jp) { + if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it)); + else if (typeof jp === 'string') { + try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); } + } else if (typeof jp === 'object') pushDate(jp.date || null); + } + } catch (e) {} + return result.size ? result : null; + }, [parsePerturbArretsObject]); + + const parseDelayMinutesFromArret = useCallback((arret) => { + if (!arret) return null; + const keys = ['retard_minutes', 'retard', 'delay_minutes', 'delay', 'retard_min']; + for (const k of keys) { + if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') { + const n = Number(arret[k]); + if (!isNaN(n)) return Math.round(n); + } + } + return null; + }, []); + + const buildPerturbMap = useCallback((list = perturbations, horairesList = allHoraires) => { + const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] }; + (list || []).forEach(p => { + const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID; + if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); } + const number = p.number || p.train_number || p.train || p.numero || p.numero_train; + if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); } + const arrets = safeParseArrets(p); + if (Array.isArray(arrets) && arrets.length) { + arrets.forEach(a => { + const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code; + const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; + if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); } + if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); } + }); + } + if ((horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); } + }); + + if (Array.isArray(horairesList) && horairesList.length) { + try { + const byH = Object.keys(idx.byHoraire || {}); + if (byH && byH.length) { + for (const hid of byH) { + const h = horairesList.find(x => String(x.id || x.horaire_id || x.horaireId || x.numero) === String(hid)); + if (h) { + const num = h.numero_train || h.number || h.numero || null; + if (num != null) { + const numKey = String(num); + idx.byNumber[numKey] = idx.byNumber[numKey] || []; + (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); + } + } + } + } + } catch (e) {} + } + return idx; + }, [perturbations, allHoraires, safeParseArrets, normalizeName]); + + const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { + const map = idx || buildPerturbMap(); + const allPerturbs = []; + const hId = horaire && (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train); + const number = horaire && (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number); + + if (map) { + const addedIds = new Set(); + const addP = (p) => { + if (p && !addedIds.has(p.id)) { + addedIds.add(p.id); + allPerturbs.push(p); + } + }; + if (hId != null && map.byHoraire && map.byHoraire[String(hId)]) { + map.byHoraire[String(hId)].forEach(addP); + } + if (number != null && map.byNumber && map.byNumber[String(number)]) { + map.byNumber[String(number)].forEach(addP); + } + if (stationId != null && map.byStopId && map.byStopId[String(stationId)]) { + map.byStopId[String(stationId)].forEach(x => addP(x.perturbation)); + } + if (stationName && map.byStopName) { + const key = normalizeName(stationName); + if (map.byStopName[key]) map.byStopName[key].forEach(x => addP(x.perturbation)); + for (const existingKey of Object.keys(map.byStopName)) { + if (existingKey.includes(key) || key.includes(existingKey)) { + map.byStopName[existingKey].forEach(x => addP(x.perturbation)); + } + } + } + const trainStops = getStops(horaire); + if (Array.isArray(trainStops)) { + trainStops.forEach(s => { + const sId = s.gare_id || s.gareId || s.id || s.code; + const sName = s.gare_nom || s.nom || s.name || s.stop_name; + if (sId != null && map.byStopId && map.byStopId[String(sId)]) { + map.byStopId[String(sId)].forEach(x => addP(x.perturbation)); + } + if (sName && map.byStopName) { + const skey = normalizeName(sName); + if (map.byStopName[skey]) map.byStopName[skey].forEach(x => addP(x.perturbation)); + } + }); + } + if (map.global) { + map.global.forEach(addP); + } + } + + const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); }; + let horaireYmd = toYMD(horaire && (horaire._date || horaire.date || horaire.date_service || horaire.service_date)); + if (!horaireYmd && serviceDate) horaireYmd = serviceDate; + + let maxDelay = null; + let isSuppressed = false; + let causeDetails = ''; + + for (const p of allPerturbs) { + try { + if (horaireYmd) { + const pDate = toYMD(p.date || p.date_service); + const db = toYMD(p.date_debut || p.start_date); + const df = toYMD(p.date_fin || p.end_date); + if (pDate && pDate !== horaireYmd) continue; + if (db && df && !(horaireYmd >= db && horaireYmd <= df)) continue; + } + } catch (e) {} + try { + const joursSet = getPerturbationJours(p); + if (joursSet && horaireYmd && !joursSet.has(horaireYmd)) continue; + } catch (e) {} + + const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero; + const numberRef = p.number || p.train_number || p.numero || p.numero_train; + const appliesByRef = ref && (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero)); + const appliesByNumber = numberRef && (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number)); + + const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; + const arrets = parsedObj.arrets || []; + + const trainStops = getStops(horaire); + let trainStopMatch = false; + + const matchesTarget = (a) => { + if (!stationName && stationId == null) return false; + const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code; + const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; + if (stationId != null && String(aId) === String(stationId)) return true; + if (stationName && aName) { + const na = normalizeName(aName); + const ns = normalizeName(stationName); + return na === ns; + } + return false; + }; + + for (const a of arrets) { + const aId = a.gare_id || a.gareId || a.id || a.uic || a.code; + const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom; + const na = normalizeName(aName || ''); + + const isTarget = matchesTarget(a); + let isTrainStop = false; + if (trainStops && Array.isArray(trainStops)) { + isTrainStop = trainStops.some(s => { + const sId = s.gare_id || s.gareId || s.id || s.code; + const sName = s.gare_nom || s.nom || s.name || s.stop_name; + if (sId != null && aId != null && String(sId) === String(aId)) return true; + if (sName && aName) { + const ns = normalizeName(sName); + return ns === na; + } + return false; + }); + } + const originName = normalizeName(horaire.gare_depart_nom || horaire.gare_depart || ''); + const destName = normalizeName(horaire.gare_arrivee_nom || horaire.gare_arrivee || ''); + if (originName === na || destName === na) isTrainStop = true; + + if (isTrainStop) trainStopMatch = true; + + if (isTarget) { + const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); + if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) { + isSuppressed = true; + } + } + } + + const isGlobal = (arrets.length === 0) && !ref && !numberRef; + const applies = appliesByRef || appliesByNumber || trainStopMatch || isGlobal; + + if (applies) { + const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); + if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) { + isSuppressed = true; + } + + for (const a of arrets) { + const tType = (a.type_perturbation || a.type || '').toString().toLowerCase(); + if (tType.includes('retard') || tType.includes('delay') || /retard/.test(tType)) { + const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; + if (maxDelay == null || d > maxDelay) maxDelay = d; + } + } + if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { + const d = parseDelayMinutesFromArret(p) || 0; + if (maxDelay == null || d > maxDelay) maxDelay = d; + } + + // Extraction du motif / cause de la perturbation + const cause = p.cause_details || p.motif || p.cause || p.description || p.information || p.details || ''; + if (cause) { + causeDetails = cause; + } + } + } + + if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null, causeDetails }; + if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay, causeDetails }; + return { label: "à l'heure", detail: '', delayMinutes: null, causeDetails }; + }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); + + const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { + const map = buildPerturbMap(perturbList || []); + const targetStationId = displayStationId || gareIdParam || null; + const targetStationName = displayStationName || gareDisplay || null; + + return (list || []).map(h => { + const horaire = { ...h }; + const stops = getStops(horaire); + const stopsStatus = []; + for (let i = 0; i < stops.length; i++) { + const s = stops[i] || {}; + const stationId = s.gare_id || s.gareId || s.id || s.station_id || s.stationId || s.uic || s.code; + const stationName = s.gare_nom || s.nom || s.name || s.station_name || s.station_nom; + const st = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate); + stopsStatus.push({ stationId, stationName, status: st }); + } + + const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); + let displayTime = horaire._displayTime || formatTimeWithColon(horaire.heure_depart || horaire.time || horaire.heure || ''); + horaire._stopsStatus = stopsStatus; + + const stopForStation = (stops || []).find(s => { + const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; + const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; + const ns = normalizeName(nm || ''); + const target = normalizeName(targetStationName || ''); + return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target); + }) || null; + + horaire._status = displayStatus; + horaire._displayTime = displayTime; + const platformVal = (stopForStation && (stopForStation.quai_reel || stopForStation.quai || stopForStation.quai_prevu || stopForStation.platform)) || horaire.quai_reel || horaire.quai || horaire.quai_prevu || horaire.platform || ''; + horaire._displayPlatform = String(platformVal).trim(); + return horaire; + }); + }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); + + // Récupération Supabase avec polling + useEffect(() => { + let mounted = true; + let inFlight = false; + + const fetchData = async () => { + if (!mounted || inFlight) return; + inFlight = true; + if (!supabase) { + setLoading(false); + inFlight = false; + return; + } + setLoading(true); + setError(null); + try { + const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }, { data: travauxSubstData }] = await Promise.all([ + fetchAllRows(supabase.from('horaires').select('*')), + supabase.from('perturbations').select('*'), + supabase.from('travaux').select('*'), + supabase.from('travaux_substitutions').select('*') + ]); + + if (!mounted) { inFlight = false; return; } + if (hError || pError || tError) { + if (mounted) setError((hError || pError || tError).message || String(hError || pError || tError)); + setLoading(false); + inFlight = false; + return; + } + + let parsedHoraires = (horairesData || []).map(h => h || {}); + parsedHoraires = parsedHoraires.map(h => { + try { + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); + if (Array.isArray(parsed)) return { ...h, gares_desservies: parsed }; + } + } catch (e) {} + return h; + }); + + if (mounted) setAllHoraires(parsedHoraires || []); + + let displayHoraires = parsedHoraires.slice(); + if (gareDisplay) { + const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' ')); + displayHoraires = displayHoraires.filter(h => { + // Exclure si la gare ciblée est la gare d'arrivée finale (terminus) + const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); + if (destName === normalized) return false; + + // Inclure si gare de départ correspond + const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString()); + if (departName === normalized) return true; + + // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus + const stopsList = getStops(h); + if (Array.isArray(stopsList) && stopsList.length) { + let maxIdx = null; + for (const st of stopsList) { + if (typeof st.index !== 'undefined' && st.index !== null) { + const n = Number(st.index); + if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n); + } + } + for (const s of stopsList) { + const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); + if (!stopName) continue; + const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx)); + if (isTerminus) continue; + if (stopName === normalized) return true; + } + } + return false; + }); + } else if (gareIdParam) { + const gid = String(gareIdParam); + displayHoraires = displayHoraires.filter(h => { + // Exclure si gare d'arrivée finale correspond + const destId = (h.gare_arrivee_id || h.gare_arrivee || null); + if (destId != null && String(destId) === gid) return false; + + // Inclure si gare de départ correspond + const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; + if (departId != null && String(departId) === gid) return true; + + // Chercher dans les arrêts desservis (ignorer terminus) + const stopsList = getStops(h); + if (Array.isArray(stopsList) && stopsList.length) { + let maxIdx = null; + for (const st of stopsList) { + if (typeof st.index !== 'undefined' && st.index !== null) { + const n = Number(st.index); + if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n); + } + } + for (const s of stopsList) { + const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; + if (stopId == null) continue; + const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx)); + if (isTerminus) continue; + if (String(stopId) === gid) return true; + } + } + return false; + }); + } + + const dateKeys = ['date_depart', 'date', 'jour', 'service_date', 'date_service', 'date_dep']; + const normalizeDate = (o) => { + for (const k of dateKeys) { + if (!o) continue; + const v = o[k]; + if (!v) continue; + const d = new Date(v); + if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10); + } + return null; + }; + + const withDates = displayHoraires.map(h => ({ ...h, _date: normalizeDate(h) })); + + const parseTimeHM = (s) => { + if (!s) return null; + if (typeof s === 'number') return s; + const m = String(s).match(/(\d{1,2}):(\d{2})/); + if (m) return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); + const d = new Date(s); + if (!isNaN(d.getTime())) return d.getHours() * 60 + d.getMinutes(); + return null; + }; + + const now = new Date(); + const todayHM = now.getHours() * 60 + now.getMinutes(); + const enrich = withDates.map(h => { + let raw = h.heure_depart || h.time || h.heure || ''; + const stopsListForH = getStops(h); + if ((gareIdParam || gareDisplay) && Array.isArray(stopsListForH)) { + const byId = gareIdParam ? stopsListForH.find(s => String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null; + const byName = gareDisplay ? stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null; + const stop = byId || byName; + if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure; + } + const depHM = parseTimeHM(raw); + const displayTime = formatTimeWithColon(raw); + return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; + }); + + const pad = (n) => String(n).padStart(2, '0'); + const localDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const today = new Date(); + const todayStr = localDateStr(today); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + const tomorrowStr = localDateStr(tomorrow); + + const circulatesOn = (h, d) => { + const targetDateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const customDaysRaw = h.jours_personnalises; + if (!customDaysRaw) return false; + let customDays = []; + if (Array.isArray(customDaysRaw)) { + customDays = customDaysRaw; + } else if (typeof customDaysRaw === 'string') { + try { customDays = JSON.parse(customDaysRaw); } catch (e) { return false; } + } + if (Array.isArray(customDays)) { + const match = customDays.find(item => item.date === targetDateStr); + if (match) return Boolean(match.circule); + } + return false; + }; + + const travauxList = travauxData || []; + const travauxSubstList = travauxSubstData || []; + + const parseDateOnly = (v) => { + if (!v) return null; + const d = new Date(v); + if (isNaN(d.getTime())) return null; + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + }; + + const parseJoursTravaux = (val) => { + if (val == null) return null; + if (Array.isArray(val)) return new Set(val.map(x => String(x).toLowerCase())); + const s = String(val || ''); + const parts = s.split(/[,;\s]+/).map(p => p.trim()).filter(Boolean); + return parts.length ? new Set(parts.map(p => p.toLowerCase())) : null; + }; + + const weekdayShort = (i) => { + switch (i) { case 0: return 'dim'; case 1: return 'lun'; case 2: return 'mar'; case 3: return 'mer'; case 4: return 'jeu'; case 5: return 'ven'; case 6: return 'sam'; default: return null; } + }; + + const travauxApplies = (t, h, date) => { + if (!t) return false; + const db = parseDateOnly(t.date_debut || t.dateDebut || t.start_date); + const df = parseDateOnly(t.date_fin || t.dateFin || t.end_date); + if (!db || !df) return false; + const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + if (d < db || d > df) return false; + const joursSet = parseJoursTravaux(t.jours_travaux || t.jours || t.days); + if (joursSet) { + const name = weekdayShort(d.getDay()); + if (!name) return false; + let ok = false; + for (const v of joursSet) { if (v.includes(name) || name.includes(v)) { ok = true; break; } } + if (!ok) return false; + } + const startHM = parseTimeHM(t.heure_debut || t.heureDebut || t.start_time); + const endHM = parseTimeHM(t.heure_fin || t.heureFin || t.end_time); + if (startHM == null || endHM == null) return true; + if (typeof h._depHM === 'number') return h._depHM >= startHM && h._depHM <= endHM; + return false; + }; + + const isSubstitution = (h) => { + if (!h) return false; + return travauxSubstList.some(ts => String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero)); + }; + + const enriched = enrich.map(h => { + let linked = false; + let appliesToday = false; + let appliesTomorrow = false; + for (const t of travauxList) { + const tLine = t.ligne_id || t.ligne || t.line_id || t.line || t.ligneId || t.lineId; + const hLine = h.ligne_id || h.ligne || h.line_id || h.line || h.ligneId || h.lineId; + + if (typeof tLine !== 'undefined' && tLine !== null) { + if (typeof hLine === 'undefined' || hLine === null) continue; + if (String(tLine) !== String(hLine)) continue; + } + + const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero; + if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true; + + const substForThisTravail = travauxSubstList.some(ts => String(ts.travaux_id) === String(t.id) && (String(ts.horaire_id) === String(h.id) || String(ts.horaire_id) === String(h.numero_train) || String(ts.horaire_id) === String(h.numero))); + if (substForThisTravail) linked = true; + + if (!appliesToday && travauxApplies(t, h, today)) appliesToday = true; + if (!appliesTomorrow && travauxApplies(t, h, tomorrow)) appliesTomorrow = true; + if (linked && appliesToday && appliesTomorrow) break; + } + return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow }; + }); + + const todayCandidates = enriched.filter(h => { + if (isSubstitution(h)) { + return Boolean(h._linkedToTravaux && h._travauxAppliesToday); + } + if (h._date && h._date !== todayStr) return false; + if (!circulatesOn(h, today)) return false; + if (typeof h._depHM !== 'number') return false; + if (h._depHM < todayHM - 1) return false; + if (h._travauxAppliesToday) return false; + return true; + }); + + const tomorrowCandidates = enriched.filter(h => { + if (isSubstitution(h)) { + return Boolean(h._linkedToTravaux && h._travauxAppliesTomorrow); + } + if (h._date && h._date !== tomorrowStr) return false; + if (!circulatesOn(h, tomorrow)) return false; + if (h._travauxAppliesTomorrow) return false; + return true; + }); + + let finalList; + let serviceDateStr; + if (todayCandidates && todayCandidates.length > 0) { + finalList = todayCandidates.slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); + serviceDateStr = todayStr; + } else { + finalList = (tomorrowCandidates || []).slice().sort((a, b) => (a._depHM || 99999) - (b._depHM || 99999)); + serviceDateStr = tomorrowStr; + } + + const filteredPerturb = (perturbData || []).filter(p => { + try { + const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10); }; + const pDate = toYMD(p.date || p.date_service); + const db = toYMD(p.date_debut || p.start_date); + const df = toYMD(p.date_fin || p.end_date); + if (pDate && pDate !== serviceDateStr) return false; + if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; + try { + const joursSet = getPerturbationJours(p); + if (joursSet && !joursSet.has(serviceDateStr)) return false; + } catch (e) {} + return true; + } catch (e) { + return true; + } + }); + + const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr); + + setHoraires(annotated || []); + setPerturbations(filteredPerturb || []); + setTravaux(travauxData || []); + setTravauxSubstitutions(travauxSubstData || []); + } catch (err) { + console.error('Erreur Supabase EVADeparts', err); + if (mounted) setError(err.message || String(err)); + } finally { + if (mounted) setLoading(false); + inFlight = false; + } + }; + + fetchData(); + const intervalId = setInterval(fetchData, 15000); + return () => { mounted = false; clearInterval(intervalId); }; + }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, normalizeName, applyPerturbationsToHoraires, getStops, getPerturbationJours]); + + // Alternance retard / texte toutes les 2s + const [alternateDelay, setAlternateDelay] = useState(false); + useEffect(() => { + const delayTimer = setInterval(() => { + setAlternateDelay(prev => !prev); + }, 2000); + return () => clearInterval(delayTimer); + }, []); + + // Pagination : 4 trains par page sur les écrans EVA + const ITEMS_PER_PAGE = 4; + const totalPages = Math.max(1, Math.ceil(horaires.length / ITEMS_PER_PAGE)); + const [pageIndex, setPageIndex] = useState(lockedPageIndex !== null ? lockedPageIndex : 0); + + useEffect(() => { + if (lockedPageIndex !== null) { + setPageIndex(Math.min(lockedPageIndex, totalPages - 1)); + return; + } + const pageInterval = setInterval(() => { + setPageIndex((prev) => { + if (totalPages <= 1) return 0; + return (prev + 1) % totalPages; + }); + }, 10000); // 10 secondes par page + return () => clearInterval(pageInterval); + }, [lockedPageIndex, totalPages]); + + const displayedHoraires = horaires.slice(pageIndex * ITEMS_PER_PAGE, (pageIndex + 1) * ITEMS_PER_PAGE); + + // Recherche d'un message de travaux ou d'alerte pour le bandeau bas + const activeTravauxMessage = (() => { + if (Array.isArray(travaux) && travaux.length > 0) { + const activeT = travaux.find(t => { + const txt = t.message || t.description || t.texte || t.motif || t.details || t.titre || t.commentaire || ''; + return Boolean(txt && txt.trim().length > 0); + }); + if (activeT) { + return activeT.message || activeT.description || activeT.texte || activeT.motif || activeT.details || activeT.titre || activeT.commentaire; + } + } + if (Array.isArray(perturbations) && perturbations.length > 0) { + const activeP = perturbations.find(p => { + const txt = p.message || p.description || p.texte || p.cause_details || ''; + return Boolean(txt && txt.trim().length > 0); + }); + if (activeP) { + return activeP.message || activeP.description || activeP.texte || activeP.cause_details; + } + } + return null; + })(); + + return ( +
+ {/* HEADER */} +
+
+ +
+ Départs + / + Departures To / Abfahrten Nach +
+
+ +
+ +
+
+ + {/* LISTE DES TRAINS (Cartes Départs) */} +
    + {displayedHoraires.length === 0 ? ( +
  • +
    + Aucun départ prévu pour le moment. +
    +
  • + ) : ( + displayedHoraires.map((h, idx) => { + const horaire = h || {}; + const op = (horaire.type_train || horaire.op || horaire.type || 'TER').toUpperCase(); + const number = horaire.numero_train || horaire.number || horaire.numero || ''; + + const rawPlatform = ( + horaire._displayPlatform || + horaire.quai_reel || + horaire.quai || + horaire.quai_prevu || + horaire.platform || + horaire.voie || + horaire.track || + '' + ).toString().trim(); + + const isCG = rawPlatform.toUpperCase() === 'CG' || + rawPlatform.toUpperCase().includes('CG') || + String(horaire.quai || '').toUpperCase().includes('CG') || + String(horaire.quai_reel || '').toUpperCase().includes('CG') || + String(horaire.quai_prevu || '').toUpperCase().includes('CG') || + String(horaire.platform || '').toUpperCase().includes('CG'); + + const isCarTER = isCG || + op.includes('CAR') || + op.includes('AUTOCAR') || + op.includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('BUS') || + String(horaire.mode || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('CAR') || + String(horaire.mode_transport || '').toUpperCase().includes('BUS') || + String(horaire.type_train || '').toUpperCase().includes('CAR') || + String(horaire.type_train || '').toUpperCase().includes('BUS') || + String(horaire.libelle_type_train || '').toUpperCase().includes('CAR') || + String(horaire.libelle_type_train || '').toUpperCase().includes('BUS') || + (travauxSubstitutions || []).some(ts => String(ts.horaire_id) === String(horaire.id) || String(ts.horaire_id) === String(horaire.numero_train) || String(ts.horaire_id) === String(horaire.numero)); + + const time = horaire._displayTime || formatTimeWithColon(horaire.heure_depart || horaire.time || horaire.heure || ''); + const destination = horaire.gare_arrivee_nom || horaire.gare_arrivee || 'Destination inconnue'; + + const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); + const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null); + const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + + const isSuppressed = status && status.label && String(status.label).toLowerCase().includes('supprimé'); + const isDelayed = status && status.label && String(status.label).toLowerCase().includes('retard'); + + let delayVal = '+10 min'; + if (typeof status?.delayMinutes === 'number' && status.delayMinutes > 0) { + delayVal = `+${status.delayMinutes} min`; + } else if (status?.delayMinutes) { + delayVal = `+${status.delayMinutes} min`; + } else if (status?.label && status.label.includes('+')) { + const m = status.label.match(/\+(\d+)\s*(?:min)?/); + if (m) delayVal = `+${m[1]} min`; + else delayVal = status.label; + } + + let pillText = isSuppressed ? 'supprimé' : isDelayed ? (alternateDelay ? 'retardé' : delayVal) : 'à l\'heure'; + const pillModifier = isSuppressed ? 'suppressed' : isDelayed ? 'delayed' : 'ontime'; + + // Motif de perturbation (ex: Travaux, Panne, etc.) + const perturbationReason = decodeText(status?.causeDetails || (isDelayed && !status?.causeDetails ? 'Retard' : '')); + + // Calcul des gares intermédiaires (Vias) + const stopsList = getStops(horaire); + const viaNames = []; + if (Array.isArray(stopsList)) { + let reachedStation = false; + for (const s of stopsList) { + const sName = s.gare_nom || s.nom || s.name || s.station_name || ''; + if (!sName) continue; + const norm = normalizeName(sName); + if (!reachedStation) { + if (norm === normalizeName(stationName || '')) reachedStation = true; + continue; + } + if (norm === normalizeName(destination || '')) break; + viaNames.push(decodeText(sName)); + } + } + + const decodedDest = decodeText(destination); + + return ( +
  • + {/* COLONNE GAUCHE : HEURE & PASTILLE DE STATUT */} +
    +
    {time}
    +
    + {isSuppressed ? ( + + ) : ( + + )} + {pillText} +
    +
    + + {/* SECTION CENTRALE : DESTINATION & MOTIF PERTURBATION & VIAS */} +
    +
    + + {perturbationReason && ( +
    + + {perturbationReason} +
    + )} +
    + +
    + + {/* SECTION DROITE : MODE / VOIE & BLOC OPERATEUR/NUMERO */} +
    + {isCarTER ? ( +
    + +
    + ) : (rawPlatform && !isCG) ? ( +
    + Voie + {rawPlatform} +
    + ) : null} + +
    + {op} + {number} +
    +
    +
  • + ); + }) + )} +
+ + {/* BANDEAU BAS : INFORMATION TRAVAUX / PERTURBATION AVEC SCROLL PROGRESSIF */} + {activeTravauxMessage && ( + + )} +
+ ); +}