diff --git a/public/type/data.json b/public/type/data.json index c1787ef..3bb7a4b 100644 --- a/public/type/data.json +++ b/public/type/data.json @@ -113,7 +113,7 @@ "file": "logo-ouigo.svg", "path": "/img/type/logo-ouigo.svg", "slug": "ouigo", - "name": "OUIGO" + "name": "TGV Ouigo" }, { "file": "logo-ouigo-classique.svg", @@ -263,67 +263,67 @@ "file": "logo-ter-rhone-alpes.svg", "path": "/img/type/logo-ter-rhone-alpes.svg", "slug": "ter-rhone-alpes", - "name": "TER Rhone Alpes" + "name": "TER Rhone-Alpes" }, { "file": "logo-teralsace.svg", "path": "/img/type/logo-teralsace.svg", "slug": "teralsace", - "name": "Teralsace" + "name": "Ter Alsace" }, { "file": "logo-teraquitaine.svg", "path": "/img/type/logo-teraquitaine.svg", "slug": "teraquitaine", - "name": "Teraquitaine" + "name": "Ter Aquitaine" }, { "file": "logo-terbassenormandie.svg", "path": "/img/type/logo-terbassenormandie.svg", "slug": "terbassenormandie", - "name": "Terbassenormandie" + "name": "Ter Basse Normandie" }, { "file": "logo-terbourgogne.svg", "path": "/img/type/logo-terbourgogne.svg", "slug": "terbourgogne", - "name": "Terbourgogne" + "name": "Ter Bourgogne" }, { "file": "logo-terbretagne.svg", "path": "/img/type/logo-terbretagne.svg", "slug": "terbretagne", - "name": "Terbretagne" + "name": "Ter Bretagne" }, { "file": "logo-tercentre.svg", "path": "/img/type/logo-tercentre.svg", "slug": "tercentre", - "name": "Tercentre" + "name": "Ter Centre" }, { "file": "logo-terlanguedocroussillon.svg", "path": "/img/type/logo-terlanguedocroussillon.svg", "slug": "terlanguedocroussillon", - "name": "Terlanguedocroussillon" + "name": "Ter Languedoc-Roussillon" }, { "file": "logo-termidipyrenees.svg", "path": "/img/type/logo-termidipyrenees.svg", "slug": "termidipyrenees", - "name": "Termidipyrenees" + "name": "Ter Midi-Pyrenees" }, { "file": "logo-ternordpasdecalais.svg", "path": "/img/type/logo-ternordpasdecalais.svg", "slug": "ternordpasdecalais", - "name": "Ternordpasdecalais" + "name": "Ter Nord-pas-de-Calais" }, { "file": "logo-terpoitoucharentes.svg", "path": "/img/type/logo-terpoitoucharentes.svg", "slug": "terpoitoucharentes", - "name": "Terpoitoucharentes" + "name": "Ter Poitou Charentes" }, { "file": "logo-tgv.svg", @@ -359,7 +359,7 @@ "file": "track-car.svg", "path": "/img/type/track-car.svg", "slug": "track-car", - "name": "Track Car" + "name": "Car TER" }, { "file": "logo-tramtrain.svg", diff --git a/query.js b/query.js new file mode 100644 index 0000000..dfec8ba --- /dev/null +++ b/query.js @@ -0,0 +1,19 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); + +const supabaseUrl = process.env.REACT_APP_SUPABASE_URL; +const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY; +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function run() { + const { data: gares, error: err1 } = await supabase.from('gares').select('*').ilike('nom', '%Ouges%').limit(1); + console.log("Gares:", gares); + if (err1) console.error(err1); + + const { data: horaires, error: err2 } = await supabase.from('horaires').select('*').limit(5); + // find one that stops at Ouges + const { data: horairesOuges, error: err3 } = await supabase.from('horaires').select('*').ilike('gares_desservies', '%Ouges%').limit(2); + console.log("Horaires Ouges:", JSON.stringify(horairesOuges, null, 2)); +} + +run(); diff --git a/query2.js b/query2.js new file mode 100644 index 0000000..5237c33 --- /dev/null +++ b/query2.js @@ -0,0 +1,11 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); + +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data, error } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies, quai_reel').or('gare_depart_nom.ilike.%Ouges%,gare_arrivee_nom.ilike.%Ouges%').limit(5); + console.log("Horaires Ouges:", JSON.stringify(data, null, 2)); +} + +run(); diff --git a/query3.js b/query3.js new file mode 100644 index 0000000..15f45ed --- /dev/null +++ b/query3.js @@ -0,0 +1,13 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); + +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data, error } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies, quai_reel').limit(10); + // find any train with Ouges + const filtered = data.filter(h => JSON.stringify(h).includes("Ouges")); + console.log("Horaires Ouges filtered:", JSON.stringify(filtered, null, 2)); +} + +run(); diff --git a/query4.js b/query4.js new file mode 100644 index 0000000..d62b50e --- /dev/null +++ b/query4.js @@ -0,0 +1,12 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); + +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data, error } = await supabase.from('horaires').select('*').limit(1); + console.log("Horaires schema:", Object.keys(data[0] || {})); + if (error) console.error("Error:", error); +} + +run(); diff --git a/query5.js b/query5.js new file mode 100644 index 0000000..e956468 --- /dev/null +++ b/query5.js @@ -0,0 +1,19 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); + +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data, error } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies').or('gare_depart_nom.ilike.%Ouges%,gare_arrivee_nom.ilike.%Ouges%'); + + if (!data || data.length === 0) { + // try to fetch all and filter in JS + const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies').limit(200); + const filtered = allData.filter(h => JSON.stringify(h).includes("Ouges")); + console.log("Horaires Ouges (JS filter):", JSON.stringify(filtered.slice(0, 2), null, 2)); + } else { + console.log("Horaires Ouges:", JSON.stringify(data.slice(0, 2), null, 2)); + } +} + +run(); diff --git a/src/App.js b/src/App.js index d841d89..7a9736f 100644 --- a/src/App.js +++ b/src/App.js @@ -10,6 +10,11 @@ import ClassiquesArrivees from './pages/afficheurs/classiques/ClassiquesArrivees import ClassiquesDepartsQuais from './pages/afficheurs/classiques/quais/ClassiquesDepartsQuais'; import sessionMemory from './lib/sessionMemory'; import ClassiquesArriveesQuais from "./pages/afficheurs/classiques/quais/ClassiquesArriveesQuais"; +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 SelectAfficheur from './pages/SelectAfficheur'; applyPolyfills().then(() => defineCustomElements(window)); // Petit routeur client sans dépendances externes @@ -130,6 +135,18 @@ function App() { if (route.startsWith('/afficheurs/departs/classiques')) { return ; } + if (route.startsWith('/afficheurs/departs/afl/quais')) { + return ; + } + if (route.startsWith('/afficheurs/departs/afl')) { + return ; + } + if (route.startsWith('/afficheurs/arrivees/afl/quais')) { + return ; + } + if (route.startsWith('/afficheurs/arrivees/afl')) { + return ; + } if (route.startsWith('/afficheurs/arrivees/classiques')) { return ; } @@ -139,6 +156,13 @@ function App() { content = navigate('/')} navigate={navigate} />; } else if (route === '/arrivees') { content = navigate('/')} navigate={navigate} />; + } else if (route === '/select-afficheur') { + content = { + const params = new URLSearchParams(window.location.search); + const gare = params.get('gare') || ''; + const mode = params.get('mode') || 'departs'; + navigate(`/afficheurs/${encodeURIComponent(gare)}?mode=${mode}`); + }} navigate={navigate} />; } else if (route.startsWith('/afficheurs')) { content = navigate('/')} navigate={navigate} />; } else { diff --git a/src/components/Logo.js b/src/components/Logo.js new file mode 100644 index 0000000..442788f --- /dev/null +++ b/src/components/Logo.js @@ -0,0 +1,44 @@ +import React, { useState, useEffect } from 'react'; +import useTypeLogos from '../hooks/useTypeLogos'; + +function getTypeLogo(type, map) { + if (!type) return map.default; + const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/-$/, ''); + if (map[norm]) return map[norm]; + + // Also try to match the first word + const firstWord = norm.split('-')[0]; + if (firstWord && map[firstWord]) return map[firstWord]; + + const keys = Object.keys(map).filter(k => k !== 'default'); + for (const k of keys) { + if (k === norm || k.includes(norm) || norm.includes(k)) return map[k]; + } + return map.default; +} + +export default function Logo({ op, classNameBox = '', classNameImg = '', classNameText = '' }) { + const typeLogos = useTypeLogos(); + const [hasImg, setHasImg] = useState(true); + + const src = getTypeLogo(op, typeLogos); + + useEffect(() => { + setHasImg(true); // Reset when op or typeLogos changes + }, [src]); + + return ( +
+ {hasImg && src ? ( + {op} setHasImg(false)} + /> + ) : ( +
{(op || '').split(' ')[0]}
+ )} +
+ ); +} diff --git a/src/components/MarqueeText.css b/src/components/MarqueeText.css new file mode 100644 index 0000000..23e07ad --- /dev/null +++ b/src/components/MarqueeText.css @@ -0,0 +1,9 @@ +@keyframes marquee-scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-100%); } +} + +.marquee-container { + display: flex; + align-items: center; +} diff --git a/src/components/MarqueeText.js b/src/components/MarqueeText.js new file mode 100644 index 0000000..5f567b4 --- /dev/null +++ b/src/components/MarqueeText.js @@ -0,0 +1,36 @@ +import React, { useRef, useState, useEffect } from 'react'; +import './MarqueeText.css'; + +const MarqueeText = ({ text, className }) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useEffect(() => { + const checkOverflow = () => { + if (containerRef.current && textRef.current) { + setIsOverflowing(textRef.current.scrollWidth > containerRef.current.clientWidth); + } + }; + checkOverflow(); + window.addEventListener('resize', checkOverflow); + return () => window.removeEventListener('resize', checkOverflow); + }, [text]); + + return ( +
+
+ {text} +
+
+ ); +}; + +export default MarqueeText; diff --git a/src/hooks/useTypeLogos.js b/src/hooks/useTypeLogos.js new file mode 100644 index 0000000..4ad943c --- /dev/null +++ b/src/hooks/useTypeLogos.js @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; + +export default function useTypeLogos(isQuai = false) { + const [map, setMap] = useState({ default: '/type/sncf-voyageurs-logo.png' }); + + useEffect(() => { + let mounted = true; + + const load = async () => { + const candidates = isQuai ? ['/type/quais/data-color.json', '/type/data.json'] : ['/type/data.json']; + + for (const url of candidates) { + try { + const res = await fetch(url); + if (!res.ok) continue; + const data = await res.json(); + if (!mounted) return; + + const logos = {}; + if (Array.isArray(data.logos)) { + data.logos.forEach(item => { + if (!item.name || !item.path) return; + + const key = String(item.name).toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/-$/, ''); + + let path = item.path.replace('/img', ''); + + logos[key] = path; + + if (item.slug) { + const slugKey = item.slug.toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/-$/, ''); + if (!logos[slugKey]) { + logos[slugKey] = path; + } + } + }); + } + + logos.default = logos['SNCF'] || logos['TER'] || '/type/sncf-voyageurs-logo.png'; + + setMap(logos); + return; // Stop after first successful fetch + } catch (e) { + console.error(`Failed to load logos data from ${url}`, e); + } + } + }; + + load(); + return () => { mounted = false; }; + }, [isQuai]); + + return map; +} diff --git a/src/pages/Displays.js b/src/pages/Displays.js index 1364ab8..cf6c0e8 100644 --- a/src/pages/Displays.js +++ b/src/pages/Displays.js @@ -60,14 +60,9 @@ export default function Displays({ onBack, navigate }) {
{types.map((t) => (
{ - // Si un navigate est fourni par le routeur simple, on l'utilise if (typeof navigate === 'function') { - // construire l'URL finale au format strict demandé : - // /afficheurs/{mode}/{type}?gare= const kebab = gare ? String(gare).trim().replace(/\s+/g, '-').replace(/\//g, '/') : ''; - const gareQuery = kebab ? `?gare=${encodeURIComponent(kebab)}` : ''; - // Construire l'URL au format demandé : /afficheurs/{mode}/{type}?gare=... - navigate(`/afficheurs/${mode}/${t.id}${gareQuery}`); + navigate(`/select-afficheur?gare=${encodeURIComponent(kebab)}&mode=${mode}&type=${t.id}`); return; } alert(`${t.label} pour ${gare || '—'}`); diff --git a/src/pages/SelectAfficheur.js b/src/pages/SelectAfficheur.js new file mode 100644 index 0000000..9f3681b --- /dev/null +++ b/src/pages/SelectAfficheur.js @@ -0,0 +1,279 @@ +import React, { useEffect, useState } from 'react'; +import supabase from '../lib/supabase'; +import WcsButton from '../components/WcsButton'; + +export default function SelectAfficheur({ onBack, navigate }) { + const params = new URLSearchParams(window.location.search || ''); + const gareParam = params.get('gare') || ''; + const modeParam = params.get('mode') || 'departs'; + const typeParam = params.get('type') || 'afl'; + + const [platforms, setPlatforms] = useState([]); + const [selectedPlatform, setSelectedPlatform] = useState(''); + const [manualPlatform, setManualPlatform] = useState(''); + const [loading, setLoading] = useState(true); + + // Decode and format station name for display + const decodedGare = gareParam + ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') + : ''; + + useEffect(() => { + let mounted = true; + async function loadPlatforms() { + if (!supabase) { + setLoading(false); + return; + } + try { + setLoading(true); + const { data, error } = await supabase.from('horaires').select('stops, quai_reel, voie, quai, gare_depart_nom, gare_arrivee_nom'); + if (error) throw error; + if (!mounted) return; + + const platformSet = new Set(); + const normGare = decodedGare.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + + (data || []).forEach(h => { + let stopsList = []; + if (Array.isArray(h.stops)) { + stopsList = h.stops; + } else if (typeof h.stops === 'string') { + try { stopsList = JSON.parse(h.stops); } catch (e) {} + } + if (!Array.isArray(stopsList)) stopsList = []; + + let foundStop = stopsList.find(s => { + const name = (s.gare_nom || s.nom || s.name || '').toString().normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + return name === normGare || name.includes(normGare) || normGare.includes(name); + }); + + if (foundStop) { + const platform = (foundStop.quai_reel || foundStop.quai || foundStop.platform || foundStop.quay || '').toString().trim(); + if (platform) platformSet.add(platform); + } else { + const origin = (h.gare_depart_nom || '').toString().normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + const dest = (h.gare_arrivee_nom || '').toString().normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + if (origin === normGare || origin.includes(normGare) || normGare.includes(origin) || + dest === normGare || dest.includes(normGare) || normGare.includes(dest)) { + const platform = (h.quai_reel || h.voie || h.quai || '').toString().trim(); + if (platform) platformSet.add(platform); + } + } + }); + + const sortedPlatforms = Array.from(platformSet).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); + setPlatforms(sortedPlatforms); + if (sortedPlatforms.length > 0) { + setSelectedPlatform(sortedPlatforms[0]); + } + } catch (err) { + console.error('Error fetching platforms:', err); + } finally { + if (mounted) setLoading(false); + } + } + + loadPlatforms(); + return () => { + mounted = false; + }; + }, [gareParam, decodedGare]); + + const handleGeneral = () => { + if (typeof navigate === 'function') { + const kebab = gareParam ? String(gareParam).trim() : ''; + navigate(`/afficheurs/${modeParam}/${typeParam}?gare=${encodeURIComponent(kebab)}`); + } + }; + + const handlePlatform = () => { + const quai = manualPlatform.trim() || selectedPlatform; + if (!quai) { + alert("Veuillez sélectionner ou saisir une voie."); + return; + } + if (typeof navigate === 'function') { + const kebab = gareParam ? String(gareParam).trim() : ''; + navigate(`/afficheurs/${modeParam}/${typeParam}/quais?gare=${encodeURIComponent(kebab)}&quai=${encodeURIComponent(quai)}`); + } + }; + + return ( +
+

+ Sélection de l'Afficheur +

+

+ Pour la gare de {decodedGare} en mode {modeParam === 'arrivees' ? 'Arrivées' : 'Départs'}. +

+ +
+ {/* General Afficheur Option */} +
+
+

Afficheur Général

+

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

+
+ +
+ + {/* Platform-specific Afficheur Option */} +
+
+

Afficheur par Quai

+

+ Affiche le prochain train et sa desserte spécifiquement pour une voie/quai. +

+ + {loading ? ( +
Chargement des voies...
+ ) : ( +
+ {platforms.length > 0 ? ( +
+ + +
+ ) : ( +
+ Aucune voie détectée automatiquement pour cette gare. +
+ )} + +
+ + setManualPlatform(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + borderRadius: '6px', + border: '1px solid #ccc', + fontSize: '14px', + boxSizing: 'border-box' + }} + /> +
+
+ )} +
+ +
+
+ +
+ { + if (typeof onBack === 'function') { + onBack(); + } else if (typeof navigate === 'function') { + navigate(`/afficheurs?gare=${encodeURIComponent(gareParam)}&mode=${modeParam}`); + } + }} + label="Retour aux types d'afficheurs" + /> +
+
+ ); +} diff --git a/src/pages/afficheurs/afl/AFLArrivees.css b/src/pages/afficheurs/afl/AFLArrivees.css new file mode 100644 index 0000000..b40ed19 --- /dev/null +++ b/src/pages/afficheurs/afl/AFLArrivees.css @@ -0,0 +1,382 @@ +/* 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__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; +} + +.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-arrivees__header-left { + display: flex; + align-items: center; + gap: 1.5vw; + width: 30vw; +} + +.afl-arrivees__title { + font-size: 5vh; + font-weight: 700; +} + +.afl-arrivees__header-center { + display: flex; + align-items: center; + justify-content: center; + gap: 1vw; + flex: 1; +} + +.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-arrivees__page-dot--active { + background-color: #4caf50; /* Cyan for active */ +} + +.afl-arrivees__header-right { + display: flex; + justify-content: flex-end; + width: 30vw; +} + +.afl-arrivees__header-right .aff-classiques-departs__clock-box { + background: transparent; + border: none; + box-shadow: none; +} + +.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; +} + +.afl-arrivees__list { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + display: flex; + flex-direction: column; +} + +.afl-arrivees__row { + display: flex; + align-items: flex-start; + padding: 2.5vh 2vw 0 2vw; + position: relative; + height: 17.5vh; + box-sizing: border-box; +} + +.afl-arrivees__col-time-status { + width: 16vw; + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.afl-arrivees__time { + font-size: 7vh; + font-weight: 700; + color: #fce205; /* Bright yellow */ + line-height: 1; + letter-spacing: -2px; +} + +.afl-arrivees__time--suppressed { + color: #ff4444; + font-size: 4vh; + text-transform: uppercase; +} + +.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; +} + +.afl-arrivees__status-pill svg { + margin-right: 0.5vw; +} + +.afl-arrivees__status-pill--delayed { + background-color: #ff9e43; + color: #111; +} + +.afl-arrivees__status-pill--suppressed { + background-color: #ff4c5b; + color: #111; +} + +.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-arrivees__op-name { + font-size: 3vh; + font-weight: 700; + line-height: 1.2; +} + +.afl-arrivees__op-number { + font-size: 2.5vh; + font-weight: 400; + color: #a4d2b1; + margin-top: 0.5vh; +} + +.afl-arrivees__col-destination { + flex: 1; + display: flex; + flex-direction: column; + padding-left: 2vw; + padding-right: 2vw; + padding-top: 0; + overflow: hidden; +} + +.afl-arrivees__dest-line { + display: flex; + align-items: center; + gap: 1vw; + margin-bottom: 0.5vh; +} + +.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; +} + +.afl-arrivees__vias-wrapper { + display: flex; + align-items: center; + width: 100%; +} + +.afl-arrivees__vias-static { + font-size: 3.5vh; + font-weight: 400; + color: #a4d2b1; + white-space: pre; +} + +.afl-arrivees__vias-container { + flex: 1; + overflow: hidden; + white-space: nowrap; + position: relative; +} + +.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%; +} + +@keyframes afl-marquee { + 0% { transform: translate(0, 0); } + 100% { transform: translate(-100%, 0); } +} + +.afl-arrivees__col-platform { + width: 12vw; + flex-shrink: 0; + display: flex; + justify-content: flex-end; + padding-top: 0; +} + +.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); +} + +.afl-arrivees__platform-label { + color: #1e4a30; /* Matches background */ + font-size: 2.8vh; + font-weight: 700; + line-height: 1.2; +} + +.afl-arrivees__platform-value { + color: #1e4a30; /* Matches background */ + font-size: 6.8vh; + font-weight: 700; + line-height: 1.1; +} + +.afl-arrivees__separator-wrapper { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2vh; + display: flex; + align-items: center; + justify-content: center; +} + +.afl-arrivees__separator { + position: absolute; + height: 0.4vh; + background-color: #16552a; + width: 96vw; + border-radius: 0.2vh; +} + +.afl-arrivees__train-illustration { + position: absolute; + z-index: 2; + background-color: #1e4a30; /* matches background */ + padding: 0 1vw; +} + +.afl-arrivees__train-illustration svg { + display: block; +} + +body:has(.afl-arrivees__container) { + margin: 0; + padding: 0; + overflow: hidden; +} + +.afl-arrivees__container * { + box-sizing: border-box; +} + +.afl-arrivees__separator-wrapper { + position: relative; + width: 100%; + height: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.afl-arrivees__separator { + position: absolute; + height: 4px; + background-color: #16552a; + width: calc(100% - 48px); + border-radius: 2px; +} + +.afl-arrivees__train-illustration { + position: absolute; + z-index: 2; + background-color: #01347d; /* match row background so it overlays the line */ + padding: 0 10px; +} + +.afl-arrivees__row--compact { + height: 10.5vh; + align-items: center; + padding-top: 0; + padding-bottom: 0; +} + +.afl-arrivees__row--compact .afl-arrivees__platform-box { + height: 8vh; +} + +.afl-arrivees__row--compact .afl-arrivees__platform-label { + font-size: 1.8vh; +} + +.afl-arrivees__row--compact .afl-arrivees__platform-value { + font-size: 4vh; +} + +.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; +} diff --git a/src/pages/afficheurs/afl/AFLArrivees.js b/src/pages/afficheurs/afl/AFLArrivees.js new file mode 100644 index 0000000..1fb3260 --- /dev/null +++ b/src/pages/afficheurs/afl/AFLArrivees.js @@ -0,0 +1,1266 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import './AFLArrivees.css'; +import supabase from '../../../lib/supabase'; +import MarqueeText from '../../../components/MarqueeText'; + +// Helper utilitaire : format HHhMM sans secondes (ex: 06h44) +const formatTimeNoSeconds = (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}h${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')}h${m[2]}`; + const m2 = v.match(/T(\d{2}):(\d{2})/); + if (m2) return `${m2[1]}h${m2[2]}`; + return v.slice(0,5).replace(':','h'); + } + 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) + +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 AFLArrivees(){ + // lire le paramètre de requête ?gare=Nom-de-la-gare + const params = new URLSearchParams(window.location.search); + const gareParam = params.get('gare'); + // support pour ?gare_id=123 ou ?gareId=123 + const gareIdParam = params.get('gare_id') || params.get('gareId'); + // gérer les +, décoder et remplacer les tirets par des espaces + const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : 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 + 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 + const [currentTime, setCurrentTime] = useState(new Date()); + + // Met à jour l'heure actuelle chaque seconde + useEffect(() => { + const id = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(id); + }, []); + + // Données depuis Supabase + const [horaires, setHoraires] = useState([]); + const [allHoraires, setAllHoraires] = useState([]); + const [perturbations, setPerturbations] = useState([]); + // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...) + const [stationType, setStationType] = useState(null); + const [, setTravaux] = useState([]); + const [, 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: [] }); + + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent + useEffect(() => { + let mounted = true; + const fetchStationType = async () => { + try { + let fetchedType = 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; + } 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; + }); + fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + } + } + if (mounted) { + setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + } + } catch (e) { /* ignore */ } + }; + if (supabase) { + fetchStationType(); + } + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + + // --- Helpers pour perturbations (définis ici pour éviter use-before-define dans useEffect) --- + 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); + // some payloads wrap { arrets: [...], jours_perturbation: [...] } + if (parsed && typeof parsed === 'object') { + if (Array.isArray(parsed.arrets)) return parsed.arrets; + // if parsed itself is an array-like object, return it + return parsed; + } + return []; + } catch (e) { return []; } + }, []); + + // Retourne la liste d'arrêts d'un horaire, supporte plusieurs noms de champs (stops, gares_desservies...) + 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; + // parfois la colonne est stockée sous forme de chaîne JSON + 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 []; + }, []); + + // parse perturbations_arrets as object to extract both arrets and jours_perturbation + 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 }; + } + }, []); + + // extrait un set de dates 'YYYY-MM-DD' depuis p.jours_perturbation (top-level) ou depuis perturbations_arrets.jours_perturbation + const getPerturbationJours = useCallback((p) => { + if (!p) return null; + // check top-level field first + 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); + } + } + // fallback: inspect perturbations_arrets object + 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; + }, []); + + // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant) + const parseTimeHM = useCallback((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; + }, []); + + // retourne le threshold (en minutes) pour afficher le quai selon le type de gare + const thresholdForStationType = useCallback((type) => { + if (!type) return 30; // 30 minutes par défaut + const t = String(type).trim().toLowerCase(); + if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures + if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes + return 30; // 30 minutes par défaut + }, []); + + const formatTimeFromHM = useCallback((totalMinutes) => { + if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; + const m = ((totalMinutes % 1440) + 1440) % 1440; + const hh = String(Math.floor(m / 60)).padStart(2, '0'); + const mm = String(m % 60).padStart(2, '0'); + return `${hh}h${mm}`; + }, []); + + // buildPerturbMap now accepts an optional list of horaires to link horaire_id -> numero_train + 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 we have horairesList, propagate horaire_id perturbations to byNumber using the matching horaire's numero_train + 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] || []; + // append all perturbations that reference this horaire id + (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); + } + } + } + } + } catch (e) { + // ignore mapping errors + } + } + + return idx; + }, [perturbations, allHoraires, safeParseArrets, normalizeName]); + + + const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => { + const res = []; + const map = idx || buildPerturbMap(); + try { debug('findPerturbsForHoraireAndStop', { horaireId: horaire && (horaire.id || horaire.horaire_id), numero: horaire && (horaire.numero_train || horaire.numero), stationId, stationName, counts: { byHoraire: Object.keys(map.byHoraire||{}).length, byNumber: Object.keys(map.byNumber||{}).length } }); } catch (e) {} + 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 (hId != null) { + const kh = String(hId); + if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); + } + if (number != null) { + const kn = String(number); + if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); + } + if (stationId != null) { + const ks = String(stationId); + if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); + } + if (stationName && map.byStopName) { + const key = normalizeName(stationName); + if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation)); + + // recherche permissive : accepter les clés qui contiennent ou sont contenues par le needle + try { + const needle = key; + for (const existingKey of Object.keys(map.byStopName || {})) { + if (existingKey === needle) continue; + if (existingKey.includes(needle) || needle.includes(existingKey)) { + map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); + } + } + } catch (e) { + // ignore + } + } + return Array.from(new Set(res)); + }, [buildPerturbMap, normalizeName, debug]); + + + // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing + // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing + 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; + + 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; + } + } + } + + if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; + if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; + return { label: "à l'heure", detail: '', delayMinutes: null }; + }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); + + // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée + const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { + const map = buildPerturbMap(perturbList || []); + // fallback to global display station context + 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 }); + } + + // compute display time using delay at the display station if any + const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); + let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); + horaire._stopsStatus = stopsStatus; + // prefer platform from stop data (quai) if available + 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]); + + 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; + let inFlight = false; // empêche les fetchs concurrents + + const fetchData = async () => { + if (!mounted) return; + if (inFlight) return; // éviter les appels simultanés si le fetch précédent n'est pas terminé + inFlight = true; + if (!supabase) { + // Supabase non initialisé — avertir et définir un message d'erreur pour l'UI + // eslint-disable-next-line no-console + console.warn('Supabase non initialisé — vérifiez REACT_APP_SUPABASE_URL et REACT_APP_SUPABASE_ANON_KEY'); + setError('Supabase non initialisé — vérifiez les variables d\'environnement'); + 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([ + supabase.from('horaires').select('id, *'), + 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; + } + + // debug: afficher ce que Supabase retourne + // eslint-disable-next-line no-console + debug('Supabase horaires fetched', { count: (horairesData || []).length, sample: (horairesData || []).slice(0,5) }); + if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) }); + + // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) + let parsedHoraires = (horairesData || []).map(h => h || {}); + + // Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD; + // essayer de parser pour garantir que stops est un tableau utilisable. + parsedHoraires = parsedHoraires.map(h => { + try { + if (h && typeof h.stops === 'string') { + const parsed = JSON.parse(h.stops); + if (Array.isArray(parsed)) { + return { ...h, stops: parsed }; + } + } + } catch (e) { + // ignore parse error + } + return h; + }); + + // debug: afficher après normalisation + // eslint-disable-next-line no-console + debug('Supabase horaires normalized', { count: parsedHoraires.length, sampleStopsExample: (parsedHoraires[0] && parsedHoraires[0].stops) || null }); + + // stocker la liste complète (non filtrée) pour associer horaire_id -> numero_train + if (mounted) setAllHoraires(parsedHoraires || []); + // Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée + // comme une gare d'arrivée (soit le terminus, soit un arrêt intermédiaire) + 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 de départ (on veut les arrivées, pas les départs) + const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); + if (departName === normalized) return false; + + // Inclure si gare d'arrivée finale correspond + const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); + if (arriveeName === normalized) return true; + + // Chercher dans les arrêts desservis ; ignorer si l'arrêt est l'origine (isFirst ou index==0) + 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; // Si c'est l'origine, ça ne peut pas être une arrivée + if (stopName === normalized) return true; + } + } + return false; + }); + } else if (gareIdParam) { + const gid = String(gareIdParam); + displayHoraires = displayHoraires.filter(h => { + // Exclure si la gare ciblée est la gare de départ (on veut les arrivées) + const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; + if (departId != null && String(departId) === gid) return false; + + // Inclure si gare d'arrivée finale correspond + const destId = (h.gare_arrivee_id || h.gare_arrivee || null); + if (destId != null && String(destId) === gid) return true; + + // Chercher dans les arrêts desservis (ignorer l'origine) + 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; + }); + } + + // Normaliser date de service (si présente) + 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) })); + + // parse HH:mm or HH:MM:SS into minutes since midnight + 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; + }; + + // enrichir withDates avec heures en minutes pour aujourd'hui et demain + 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 || ''; + // si stops contient la gare ciblée, préférer son 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 = formatTimeNoSeconds(raw); + return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; + }); + + + // fonctions utilitaires de jours + 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 weekdayIndex = (d) => d.getDay(); // 0 dimanche .. 6 samedi + const weekdayNameForColumn = (d) => { + const i = weekdayIndex(d); + switch(i) { + case 1: return 'circule_lundi'; + case 2: return 'circule_mardi'; + case 3: return 'circule_mercredi'; + case 4: return 'circule_jeudi'; + case 5: return 'circule_vendredi'; + case 6: return 'circule_samedi'; + case 0: return 'circule_dimanche'; + default: return null; + } + }; + + const isPublicHoliday = (d) => { + const y = d.getFullYear(); + const m = d.getMonth() + 1; + const day = d.getDate(); + const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const holidays = [ + `${y}-01-01`, // Jour de l'an + `${y}-05-01`, // Fête du Travail + `${y}-05-08`, // Victoire 1945 + `${y}-07-14`, // Fête nationale + `${y}-08-15`, // Assomption + `${y}-11-01`, // Toussaint + `${y}-11-11`, // Armistice 1918 + `${y}-12-25`, // Noël + ]; + // Dates variables pour 2026 + if (y === 2026) { + holidays.push('2026-04-06'); // Lundi de Pâques + holidays.push('2026-05-14'); // Ascension + holidays.push('2026-05-25'); // Lundi de Pentecôte + } + return holidays.includes(s); + }; + + const parseCirculationFlag = (value, defaultValue) => { + if (value === null || typeof value === 'undefined') { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value === 1; + } + if (typeof value === 'string') { + const s = value.trim().toLowerCase(); + if (['true', '1', 't', 'y', 'yes', 'oui'].includes(s)) { + return true; + } + if (['false', '0', 'f', 'n', 'no', 'non'].includes(s)) { + return false; + } + } + return defaultValue; + }; + + const circulatesOn = (h, d) => { + if (isPublicHoliday(d)) { + // On public holidays, circulation is determined SOLELY by `circule_jours_feries`. + // If the flag is true, it runs. Otherwise, it doesn't. + return parseCirculationFlag(h['circule_jours_feries'], false); + } + + // On a regular day, check the corresponding weekday column. + const col = weekdayNameForColumn(d); + if (!col) return true; // Should not happen, but permissive. + + // For regular weekdays, the default is to run unless specified otherwise. + return parseCirculationFlag(h[col], true); + }; + + // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* + // Calculer les travaux applicables et les substitutions + 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)); + }; + + // compute linkage and travaux applicability per horaire + const enriched = enrich.map(h => { + let linked = false; + let appliesToday = false; + let appliesTomorrow = false; + for (const t of travauxList) { + // Extraire identifiants de ligne (différentes variantes possibles) + 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; + + // Si le travail a une ligne attribuée, n'appliquer le travail QUE si la ligne correspond + // (si hLine est absent on considère qu'on ne peut pas appliquer ce travail à cet horaire) + if (typeof tLine !== 'undefined' && tLine !== null) { + if (typeof hLine === 'undefined' || hLine === null) { + // pas de ligne sur l'horaire -> ignorer ce travail pour cet horaire + continue; + } + if (String(tLine) !== String(hLine)) { + // lignes différentes -> ignorer ce travail pour cet horaire + 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; + + // Si l'horaire est explicitement listé dans travaux_substitutions pour CE travail, on le link + 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 }; + }); + + // Build today and tomorrow candidate lists + 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 travaux apply at this horaire today, hide regular horaires + 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; + + // hide regular horaires if travaux apply tomorrow during their time window + if (h._travauxAppliesTomorrow) return false; + + return true; + }); + + // final list: if there are any todayCandidates use them; otherwise use tomorrowCandidates + 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; + } + + // Construire un index local des perturbations à partir des données fraîchement récupérées + // Filtrer pour ne garder que les perturbations applicables aujourd'hui (éviter d'appliquer les perturbations d'hier) + + 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); + // si pDate fourni et ne correspond pas à la date de service -> ignorer + if (pDate && pDate !== serviceDateStr) return false; + // si fourche de dates fournie et la date de service n'est pas dans l'intervalle -> ignorer + if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; + // si jours_perturbation fourni -> vérifier que la date de service en fait partie + try { + const joursSet = getPerturbationJours(p); + if (joursSet && !joursSet.has(serviceDateStr)) return false; + } catch (e) { + // ignore parse errors and keep perturbation + } + return true; + } catch (e) { + return true; + } + }); + + const localPerturbMap = buildPerturbMap(filteredPerturb || [], parsedHoraires || []); + // Debug: afficher sommaire de l'index des perturbations + debug('localPerturbMap summary', { + horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length, + numberCount: Object.keys(localPerturbMap.byNumber || {}).length, + stopIdCount: Object.keys(localPerturbMap.byStopId || {}).length, + stopNameCount: Object.keys(localPerturbMap.byStopName || {}).length, + globalCount: (localPerturbMap.global || []).length, + sampleStopNames: Object.keys(localPerturbMap.byStopName || {}).slice(0,10) + }); + + // Annoter chaque horaire avec un statut calculé (synchronisé avec la BDD) + // déterminer la date de service utilisée pour ces finalList (aujourd'hui ou demain) + const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr); + + // 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)); + setPerturbations(filteredPerturb || []); + setTravaux(travauxData || []); + setTravauxSubstitutions(travauxSubstData || []); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Erreur Supabase horaires', err); + if (mounted) setError(err.message || String(err)); + } finally { + if (mounted) setLoading(false); + inFlight = false; + } + }; + + // Exécuter immédiatement, puis périodiquement pour synchroniser sans recharger + fetchData(); + const intervalMs = 15000; // 15 secondes par défaut + const intervalId = setInterval(fetchData, intervalMs); + + return () => { mounted = false; clearInterval(intervalId); }; + }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]); + + // Helpers pour perturbations (memoized plus haut) + + const [pageIndex, setPageIndex] = useState(0); + const [showAlternateStatus, setShowAlternateStatus] = useState(false); + + useEffect(() => { + const alternateInterval = setInterval(() => { + setShowAlternateStatus(prev => !prev); + }, 2000); + return () => clearInterval(alternateInterval); + }, []); + + const getPageBounds = useCallback((pIndex) => { + if (pIndex === 0) return { start: 0, end: 5 }; + const start = 5 + (pIndex - 1) * 8; + return { start, end: start + 8 }; + }, []); + + const totalPages = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); + + useEffect(() => { + const pageInterval = setInterval(() => { + setPageIndex((prev) => { + const tot = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); + if (tot <= 1) return 0; + return (prev + 1) % tot; + }); + }, 10000); // Change page every 10 seconds + return () => clearInterval(pageInterval); + }, [horaires.length]); + + const { start, end } = getPageBounds(pageIndex); + const displayedHoraires = horaires.slice(start, end); + + return ( +
+ {/* HEADER */} +
+
+
+ + + +
+
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)); + + // 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é'); + + let displayStatusText = "À l'heure"; + let delayVal = ''; + if (isDelayed) { + displayStatusText = status.label; + if (typeof status.delayMinutes === 'number') { + delayVal = `+${status.delayMinutes} min`; + displayStatusText = delayVal; + } + } + if (isSuppressed) displayStatusText = "Supprimé"; + + const isBus = op.includes('CAR') || op.includes('AUTOCAR') || op.includes('BUS') || (horaire.mode && horaire.mode.toLowerCase().includes('bus')); + + const isCompact = pageIndex > 0 && idx > 0; + const showCompactStatus = isCompact; + + return ( + +
  • +
    + {showCompactStatus && showAlternateStatus ? ( +
    + {isSuppressed ? ( + + + + + ) : isDelayed ? ( + + + + + ) : ( + + + + + )} + {displayStatusText} +
    + ) : ( + <> +
    {time}
    + {!isCompact && ( +
    + {isSuppressed ? ( + + + + + ) : isDelayed ? ( + + + + + ) : ( + + + + + )} + {displayStatusText} +
    + )} + + )} +
    + +
    +
    {op}
    + {!isCompact &&
    {number}
    } +
    + +
    +
    + {isBus && ( + + + + )} + +
    + {!isCompact && ( +
    + {viasStr ? ( + <> + via +
    +
    {viasStr}
    +
    + + ) : ( + direct + )} +
    + )} +
    + +
    + {isSuppressed ? ( + + + + + ) : showPlatform ? ( +
    + {!isBus &&
    Voie
    } +
    + {isBus ? ( + + + + ) : platform} +
    +
    + ) : null} +
    +
  • + {idx < displayedHoraires.length - 1 && ( +
    +
    +
    + )} + + ); + })} +
+
+ ); +} diff --git a/src/pages/afficheurs/afl/AFLArriveesQuais.css b/src/pages/afficheurs/afl/AFLArriveesQuais.css new file mode 100644 index 0000000..7fe0d60 --- /dev/null +++ b/src/pages/afficheurs/afl/AFLArriveesQuais.css @@ -0,0 +1,334 @@ +/* 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-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-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 --- */ +.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-quais-arrivees__header-left { + display: flex; + align-items: center; + gap: 1.2vw; +} + +.afl-quais-arrivees__header-title { + font-size: 5vh; + font-weight: 700; + letter-spacing: -0.02em; +} + +.afl-quais-arrivees__header-clock { + font-size: 5vh; + font-weight: 700; +} + +/* --- MAIN CONTENT --- */ +.afl-quais-arrivees__main { + flex: 1; + display: flex; + position: relative; + box-sizing: border-box; +} + +/* --- 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); +} + +.afl-quais-arrivees__time-status { + display: flex; + align-items: center; + gap: 1vw; + margin-bottom: 1.5vh; +} + +.afl-quais-arrivees__time { + font-size: 7.5vh; + font-weight: 800; + color: var(--accent-yellow); + line-height: 1; +} + +.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; +} + +.afl-quais-arrivees__status-pill svg { + margin-right: 0.5vw; +} + +.afl-quais-arrivees__status-pill--delayed { + background-color: #ff9e43; + color: #111; +} + +.afl-quais-arrivees__status-pill--suppressed { + background-color: #ff4c5b; + color: #111; +} + +.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; +} + +.afl-quais-arrivees__train-info { + font-size: 3vh; + color: var(--text-grey); + display: flex; + gap: 0.8vw; +} + +.afl-quais-arrivees__train-type { + font-weight: 800; +} + +.afl-quais-arrivees__train-number { + font-weight: 500; +} + +/* --- 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-quais-arrivees__stops-window { + width: 100%; + max-height: 100%; + overflow: hidden; + position: relative; +} + +.afl-quais-arrivees__stops-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + width: 100%; +} + +.afl-quais-arrivees__stop-item { + display: flex; + align-items: center; + height: 6.2vh; + position: relative; + box-sizing: border-box; +} + +/* 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-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; +} + +/* Adjust line ends for first and last stops */ +.afl-quais-arrivees__stop-item.is-first .afl-quais-arrivees__stop-line { + top: 50%; +} + +.afl-quais-arrivees__stop-item.is-last .afl-quais-arrivees__stop-line { + bottom: 50%; +} + +/* 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; +} diff --git a/src/pages/afficheurs/afl/AFLArriveesQuais.js b/src/pages/afficheurs/afl/AFLArriveesQuais.js new file mode 100644 index 0000000..d0592ca --- /dev/null +++ b/src/pages/afficheurs/afl/AFLArriveesQuais.js @@ -0,0 +1,866 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import supabase from '../../../lib/supabase'; +import './AFLArriveesQuais.css'; + +// Helper utilitaire : format HH:MM sans secondes (ex: 15:57) +const formatTimeNoSeconds = (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}`; + } + const parts = String(v).split(':'); + if (parts.length >= 2) { + return `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`; + } + return v; +}; + +const AFLArriveesQuais = () => { + const [currentTime, setCurrentTime] = useState(new Date()); + 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 quaiParam = searchParams.get('quai') || searchParams.get('voie') || ''; + const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + + // 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 { + 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) {} + } + } + 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 parseTimeHM = useCallback((s) => { + if (!s) return null; + if (typeof s === 'number') return s; + const m = String(s).match(/(\d{1,2})[h:](\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 buildPerturbMap = useCallback((list, allHorairesList) => { + 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(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 = []; + 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; + + 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 || na.includes(ns) || ns.includes(na); + } + 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 || ns.includes(na) || na.includes(ns); + } + 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; + } + } + } + + if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; + if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; + return { label: "à l'heure", detail: '', delayMinutes: null }; + }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); + + const applyPerturbationsToHoraires = useCallback((list, perturbList, displayStationId, displayStationName, serviceDate, map) => { + const targetStationId = displayStationId || null; + const targetStationName = displayStationName || 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 || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); + horaire._stopsStatus = stopsStatus; + horaire._status = displayStatus; + horaire._displayTime = displayTime; + return horaire; + }); + }, [computeStatusForHoraireAtStop, getStops]); + + const fetchData = useCallback(async () => { + try { + const [{ data: horairesData }, { data: subData }, { data: perturbData }, { data: travauxData }] = await Promise.all([ + supabase.from('horaires').select('*'), + supabase.from('travaux_substitutions').select('*'), + supabase.from('perturbations').select('*'), + supabase.from('travaux').select('*') + ]); + + let parsedHoraires = (horairesData || []).map(h => { + try { + if (h && typeof h.stops === 'string') { + const parsed = JSON.parse(h.stops); + if (Array.isArray(parsed)) return { ...h, stops: parsed }; + } + } catch (e) {} + return h || {}; + }); + + const substitutions = subData || []; + const travauxList = travauxData || []; + + // Link substitutions + parsedHoraires = parsedHoraires.map(h => { + const subs = substitutions.filter(s => String(s.horaire_id) === String(h.id)); + if (subs.length > 0) { + return { ...h, _substitutions: subs }; + } + return h; + }); + + // Normaliser date + 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 = parsedHoraires.map(h => ({ ...h, _date: normalizeDate(h) })); + + // Time parsing & enrichment + 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 (gareDisplay && Array.isArray(stopsListForH)) { + const stop = stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); + if (stop && (stop.heure_arrivee || stop.time || stop.heure)) raw = stop.heure_arrivee || stop.time || stop.heure; + } + const depHM = parseTimeHM(raw); + const displayTime = formatTimeNoSeconds(raw); + return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; + }); + + // Jours de circulation + 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 weekdayIndex = (d) => d.getDay(); + const weekdayNameForColumn = (d) => { + const i = weekdayIndex(d); + switch (i) { + case 1: return 'circule_lundi'; + case 2: return 'circule_mardi'; + case 3: return 'circule_mercredi'; + case 4: return 'circule_jeudi'; + case 5: return 'circule_vendredi'; + case 6: return 'circule_samedi'; + case 0: return 'circule_dimanche'; + default: return null; + } + }; + + const isPublicHoliday = (d) => { + const y = d.getFullYear(); + const m = d.getMonth() + 1; + const day = d.getDate(); + const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const holidays = [ + `${y}-01-01`, `${y}-05-01`, `${y}-05-08`, `${y}-07-14`, `${y}-08-15`, `${y}-11-01`, `${y}-11-11`, `${y}-12-25` + ]; + if (y === 2026) { + holidays.push('2026-04-06'); + holidays.push('2026-05-14'); + holidays.push('2026-05-25'); + } + return holidays.includes(s); + }; + + const parseCirculationFlag = (val, defaultVal) => { + if (val == null) return defaultVal; + if (typeof val === 'boolean') return val; + const s = String(val).trim().toLowerCase(); + if (['true', '1', 't', 'y', 'yes', 'oui'].includes(s)) return true; + if (['false', '0', 'f', 'n', 'no', 'non'].includes(s)) return false; + return defaultVal; + }; + + const circulatesOn = (h, d) => { + if (isPublicHoliday(d)) { + return parseCirculationFlag(h['circule_jours_feries'], false); + } + const col = weekdayNameForColumn(d); + if (!col) return true; + return parseCirculationFlag(h[col], true); + }; + + 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.start_date); + const df = parseDateOnly(t.date_fin || 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.start_time); + const endHM = parseTimeHM(t.heure_fin || 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 substitutions.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; + const hLine = h.ligne_id || h.ligne || h.line_id || h.line; + if (typeof tLine !== 'undefined' && tLine !== null) { + if (typeof hLine === 'undefined' || hLine === null || String(tLine) !== String(hLine)) { + continue; + } + } + const ref = t.horaire_id || t.horaire || t.numero_train || t.numero; + if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true; + const substForThisTravail = substitutions.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 }; + }); + + // Filter by station: keep arrivals to this station or stops at this station (excluding origin) + let stationFiltered = enriched; + if (gareDisplay) { + const normalized = normalizeName(gareDisplay); + stationFiltered = enriched.filter(h => { + // Exclude if target station is the departure origin + const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); + if (departName === normalized) return false; + + // Include if target station is the terminus + const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); + if (arriveeName === normalized) return true; + + // Check intermediate stops + 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 || '').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; + }); + } + + // Candidate filters for Today and Tomorrow (Arrival platform logic) + const todayCandidates = stationFiltered.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; + // Keep arrivals that occur in the future or up to 1 minute ago (arrival window) + if (h._depHM < todayHM - 1) return false; + if (h._travauxAppliesToday) return false; + return true; + }); + + const tomorrowCandidates = stationFiltered.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.length > 0) { + finalList = todayCandidates.slice().sort((a, b) => a._depHM - b._depHM); + serviceDateStr = todayStr; + } else { + finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM); + serviceDateStr = tomorrowStr; + } + + // Apply perturbations (delays, cancellations) + + 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; + const joursSet = getPerturbationJours(p); + if (joursSet && !joursSet.has(serviceDateStr)) return false; + return true; + } catch (e) { return true; } + }); + + 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; + if (gareDisplay && Array.isArray(stopsList)) { + 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 (candidatesOnPlatform.length > 0) { + setNextArrival(candidatesOnPlatform[0]); + } else { + setNextArrival(null); + } + setLoading(false); + } catch (err) { + console.error(err); + setLoading(false); + } + }, [applyPerturbationsToHoraires, buildPerturbMap, gareDisplay, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 15000); + return () => clearInterval(interval); + }, [fetchData]); + + if (loading) return
; + if (!nextArrival) return ( +
+
+
+ + + +
Prochaine arrivée
+
+
{formatTimeNoSeconds(currentTime)}
+
+
Aucune arrivée prévue
+
+ ); + + const stops = getStops(nextArrival); + let filteredStops = []; + + 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(); + } + + 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 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 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); + 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 shouldScroll = filteredStops.length > 10; + const scrollStyle = shouldScroll ? { + animation: `scrollStops ${filteredStops.length * 2.2}s linear infinite` + } : {}; + + return ( +
+ {/* Header */} +
+
+ + + +
Prochaine arrivée
+
+
{formatTimeNoSeconds(currentTime)}
+
+ +
+ {/* Left Pane (Floating Card) */} +
+
+
{displayTime}
+
+ {isSuppressed ? ( + + + + + ) : isDelayed ? ( + + + + + ) : ( + + + + + )} + {displayStatusText} +
+
+
{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; + + return ( +
  • +
    +
    +
    +
    +
    {sname}
    +
  • + ); + })} +
+
+
+
+ + {/* Symmetrical train outline under the stops list */} +
+
+ + + + + + + + + + +
+
+ + {platformStr && ( +
+
Voie
+
{platformStr}
+
+ )} +
+ ); +}; + +export default AFLArriveesQuais; diff --git a/src/pages/afficheurs/afl/AFLDeparts.css b/src/pages/afficheurs/afl/AFLDeparts.css new file mode 100644 index 0000000..7c51947 --- /dev/null +++ b/src/pages/afficheurs/afl/AFLDeparts.css @@ -0,0 +1,382 @@ +/* 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__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-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; +} + +.afl-departs__header-left { + display: flex; + align-items: center; + gap: 1.5vw; + width: 30vw; +} + +.afl-departs__title { + font-size: 5vh; + font-weight: 700; +} + +.afl-departs__header-center { + display: flex; + align-items: center; + justify-content: center; + gap: 1vw; + flex: 1; +} + +.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-departs__page-dot--active { + background-color: #00c3ff; /* Cyan for active */ +} + +.afl-departs__header-right { + display: flex; + justify-content: flex-end; + width: 30vw; +} + +.afl-departs__header-right .aff-classiques-departs__clock-box { + background: transparent; + border: none; + box-shadow: none; +} + +.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-departs__list { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + display: flex; + flex-direction: column; +} + +.afl-departs__row { + display: flex; + align-items: flex-start; + padding: 2.5vh 2vw 0 2vw; + position: relative; + height: 17.5vh; + box-sizing: border-box; +} + +.afl-departs__col-time-status { + width: 16vw; + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.afl-departs__time { + font-size: 7vh; + font-weight: 700; + color: #fce205; /* Bright yellow */ + line-height: 1; + letter-spacing: -2px; +} + +.afl-departs__time--suppressed { + color: #ff4444; + font-size: 4vh; + text-transform: uppercase; +} + +.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; +} + +.afl-departs__status-pill svg { + margin-right: 0.5vw; +} + +.afl-departs__status-pill--delayed { + background-color: #ff9e43; + color: #111; +} + +.afl-departs__status-pill--suppressed { + background-color: #ff4c5b; + color: #111; +} + +.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-departs__op-name { + font-size: 3vh; + font-weight: 700; + line-height: 1.2; +} + +.afl-departs__op-number { + font-size: 2.5vh; + font-weight: 400; + color: #cce0ff; + margin-top: 0.5vh; +} + +.afl-departs__col-destination { + flex: 1; + display: flex; + flex-direction: column; + padding-left: 2vw; + padding-right: 2vw; + padding-top: 0; + overflow: hidden; +} + +.afl-departs__dest-line { + display: flex; + align-items: center; + gap: 1vw; + margin-bottom: 0.5vh; +} + +.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; +} + +.afl-departs__vias-wrapper { + display: flex; + align-items: center; + width: 100%; +} + +.afl-departs__vias-static { + font-size: 3.5vh; + font-weight: 400; + color: #cce0ff; + white-space: pre; +} + +.afl-departs__vias-container { + flex: 1; + overflow: hidden; + white-space: nowrap; + position: relative; +} + +.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%; +} + +@keyframes afl-marquee { + 0% { transform: translate(0, 0); } + 100% { transform: translate(-100%, 0); } +} + +.afl-departs__col-platform { + width: 12vw; + flex-shrink: 0; + display: flex; + justify-content: flex-end; + padding-top: 0; +} + +.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-departs__platform-label { + color: #012a6a; /* Matches background */ + font-size: 2.8vh; + font-weight: 700; + line-height: 1.2; +} + +.afl-departs__platform-value { + color: #012a6a; /* Matches background */ + font-size: 6.8vh; + font-weight: 700; + line-height: 1.1; +} + +.afl-departs__separator-wrapper { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2vh; + display: flex; + align-items: center; + justify-content: center; +} + +.afl-departs__separator { + position: absolute; + height: 0.4vh; + background-color: #005eb8; + width: 96vw; + border-radius: 0.2vh; +} + +.afl-departs__train-illustration { + position: absolute; + z-index: 2; + background-color: #012a6a; /* matches background */ + padding: 0 1vw; +} + +.afl-departs__train-illustration svg { + display: block; +} + +body:has(.afl-departs__container) { + margin: 0; + padding: 0; + overflow: hidden; +} + +.afl-departs__container * { + box-sizing: border-box; +} + +.afl-departs__separator-wrapper { + position: relative; + width: 100%; + height: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.afl-departs__separator { + position: absolute; + height: 4px; + background-color: #007ac3; + width: calc(100% - 48px); + border-radius: 2px; +} + +.afl-departs__train-illustration { + position: absolute; + z-index: 2; + background-color: #01347d; /* match row background so it overlays the line */ + padding: 0 10px; +} + +.afl-departs__row--compact { + height: 10.5vh; + align-items: center; + padding-top: 0; + padding-bottom: 0; +} + +.afl-departs__row--compact .afl-departs__platform-box { + height: 8vh; +} + +.afl-departs__row--compact .afl-departs__platform-label { + font-size: 1.8vh; +} + +.afl-departs__row--compact .afl-departs__platform-value { + font-size: 4vh; +} + +.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; +} diff --git a/src/pages/afficheurs/afl/AFLDeparts.js b/src/pages/afficheurs/afl/AFLDeparts.js new file mode 100644 index 0000000..b6ca27e --- /dev/null +++ b/src/pages/afficheurs/afl/AFLDeparts.js @@ -0,0 +1,1281 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import './AFLDeparts.css'; +import supabase from '../../../lib/supabase'; +import MarqueeText from '../../../components/MarqueeText'; + +// Helper utilitaire : format HHhMM sans secondes (ex: 06h44) +const formatTimeNoSeconds = (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}h${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')}h${m[2]}`; + const m2 = v.match(/T(\d{2}):(\d{2})/); + if (m2) return `${m2[1]}h${m2[2]}`; + return v.slice(0,5).replace(':','h'); + } + 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) + +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 AFLDeparts(){ + // lire le paramètre de requête ?gare=Nom-de-la-gare + const params = new URLSearchParams(window.location.search); + const gareParam = params.get('gare'); + // support pour ?gare_id=123 ou ?gareId=123 + const gareIdParam = params.get('gare_id') || params.get('gareId'); + // gérer les +, décoder et remplacer les tirets par des espaces + const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : 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 + 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 + const [currentTime, setCurrentTime] = useState(new Date()); + + // Met à jour l'heure actuelle chaque seconde + useEffect(() => { + const id = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(id); + }, []); + + // Données depuis Supabase + const [horaires, setHoraires] = useState([]); + const [allHoraires, setAllHoraires] = useState([]); + const [perturbations, setPerturbations] = useState([]); + // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...) + const [stationType, setStationType] = useState(null); + const [, setTravaux] = useState([]); + const [, 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: [] }); + + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent + useEffect(() => { + let mounted = true; + const fetchStationType = async () => { + try { + let fetchedType = 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; + } 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; + }); + fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + } + } + if (mounted) { + setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + } + } catch (e) { /* ignore */ } + }; + if (supabase) { + fetchStationType(); + } + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + + // --- Helpers pour perturbations (définis ici pour éviter use-before-define dans useEffect) --- + 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); + // some payloads wrap { arrets: [...], jours_perturbation: [...] } + if (parsed && typeof parsed === 'object') { + if (Array.isArray(parsed.arrets)) return parsed.arrets; + // if parsed itself is an array-like object, return it + return parsed; + } + return []; + } catch (e) { return []; } + }, []); + + // Retourne la liste d'arrêts d'un horaire, supporte plusieurs noms de champs (stops, gares_desservies...) + 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; + // parfois la colonne est stockée sous forme de chaîne JSON + 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 []; + }, []); + + // parse perturbations_arrets as object to extract both arrets and jours_perturbation + 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 }; + } + }, []); + + // extrait un set de dates 'YYYY-MM-DD' depuis p.jours_perturbation (top-level) ou depuis perturbations_arrets.jours_perturbation + const getPerturbationJours = useCallback((p) => { + if (!p) return null; + // check top-level field first + 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); + } + } + // fallback: inspect perturbations_arrets object + 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; + }, []); + + // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant) + const parseTimeHM = useCallback((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; + }, []); + + // retourne le threshold (en minutes) pour afficher le quai selon le type de gare + const thresholdForStationType = useCallback((type) => { + if (!type) return 30; + const t = String(type).trim().toLowerCase(); + if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures + if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes + return 30; // 30 minutes par défaut + }, []); + + const formatTimeFromHM = useCallback((totalMinutes) => { + if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; + const m = ((totalMinutes % 1440) + 1440) % 1440; + const hh = String(Math.floor(m / 60)).padStart(2, '0'); + const mm = String(m % 60).padStart(2, '0'); + return `${hh}h${mm}`; + }, []); + + // buildPerturbMap now accepts an optional list of horaires to link horaire_id -> numero_train + 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 we have horairesList, propagate horaire_id perturbations to byNumber using the matching horaire's numero_train + 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] || []; + // append all perturbations that reference this horaire id + (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); + } + } + } + } + } catch (e) { + // ignore mapping errors + } + } + + return idx; + }, [perturbations, allHoraires, safeParseArrets, normalizeName]); + + + const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => { + const res = []; + const map = idx || buildPerturbMap(); + try { debug('findPerturbsForHoraireAndStop', { horaireId: horaire && (horaire.id || horaire.horaire_id), numero: horaire && (horaire.numero_train || horaire.numero), stationId, stationName, counts: { byHoraire: Object.keys(map.byHoraire||{}).length, byNumber: Object.keys(map.byNumber||{}).length } }); } catch (e) {} + 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 (hId != null) { + const kh = String(hId); + if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); + } + if (number != null) { + const kn = String(number); + if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); + } + if (stationId != null) { + const ks = String(stationId); + if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); + } + if (stationName && map.byStopName) { + const key = normalizeName(stationName); + if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation)); + + // recherche permissive : accepter les clés qui contiennent ou sont contenues par le needle + try { + const needle = key; + for (const existingKey of Object.keys(map.byStopName || {})) { + if (existingKey === needle) continue; + if (existingKey.includes(needle) || needle.includes(existingKey)) { + map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); + } + } + } catch (e) { + // ignore + } + } + return Array.from(new Set(res)); + }, [buildPerturbMap, normalizeName, debug]); + + + // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing + // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing + 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; + + 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; + } + } + } + + if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; + if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; + return { label: "à l'heure", detail: '', delayMinutes: null }; + }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); + + // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée + const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { + const map = buildPerturbMap(perturbList || []); + // fallback to global display station context + 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 }); + } + + // compute display time using delay at the display station if any + const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); + let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); + horaire._stopsStatus = stopsStatus; + // prefer platform from stop data (quai) if available + 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]); + + 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; + let inFlight = false; // empêche les fetchs concurrents + + const fetchData = async () => { + if (!mounted) return; + if (inFlight) return; // éviter les appels simultanés si le fetch précédent n'est pas terminé + inFlight = true; + if (!supabase) { + // Supabase non initialisé — avertir et définir un message d'erreur pour l'UI + // eslint-disable-next-line no-console + console.warn('Supabase non initialisé — vérifiez REACT_APP_SUPABASE_URL et REACT_APP_SUPABASE_ANON_KEY'); + setError('Supabase non initialisé — vérifiez les variables d\'environnement'); + 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([ + supabase.from('horaires').select('id, *'), + 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; + } + + // debug: afficher ce que Supabase retourne + // eslint-disable-next-line no-console + debug('Supabase horaires fetched', { count: (horairesData || []).length, sample: (horairesData || []).slice(0,5) }); + if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) }); + + // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) + let parsedHoraires = (horairesData || []).map(h => h || {}); + + // Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD; + // essayer de parser pour garantir que stops est un tableau utilisable. + parsedHoraires = parsedHoraires.map(h => { + try { + if (h && typeof h.stops === 'string') { + const parsed = JSON.parse(h.stops); + if (Array.isArray(parsed)) { + return { ...h, stops: parsed }; + } + } + } catch (e) { + // ignore parse error + } + return h; + }); + + // debug: afficher après normalisation + // eslint-disable-next-line no-console + debug('Supabase horaires normalized', { count: parsedHoraires.length, sampleStopsExample: (parsedHoraires[0] && parsedHoraires[0].stops) || null }); + + // stocker la liste complète (non filtrée) pour associer horaire_id -> numero_train + if (mounted) setAllHoraires(parsedHoraires || []); + // Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée + // (soit comme gare_depart, soit comme un arrêt intermédiaire dans `stops` / `gares_desservies`) + // Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus) + 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 + 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 (isLast ou index==max) + 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; + }); + } + + // Normaliser date de service (si présente) + 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) })); + + // parse HH:mm or HH:MM:SS into minutes since midnight + 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; + }; + + // enrichir withDates avec heures en minutes pour aujourd'hui et demain + 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 || ''; + // si stops contient la gare ciblée, préférer son 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 = formatTimeNoSeconds(raw); + return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; + }); + + + // fonctions utilitaires de jours + 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 weekdayIndex = (d) => d.getDay(); // 0 dimanche .. 6 samedi + const weekdayNameForColumn = (d) => { + const i = weekdayIndex(d); + switch(i) { + case 1: return 'circule_lundi'; + case 2: return 'circule_mardi'; + case 3: return 'circule_mercredi'; + case 4: return 'circule_jeudi'; + case 5: return 'circule_vendredi'; + case 6: return 'circule_samedi'; + case 0: return 'circule_dimanche'; + default: return null; + } + }; + + const isPublicHoliday = (d) => { + const y = d.getFullYear(); + const m = d.getMonth() + 1; + const day = d.getDate(); + const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const holidays = [ + `${y}-01-01`, // Jour de l'an + `${y}-05-01`, // Fête du Travail + `${y}-05-08`, // Victoire 1945 + `${y}-07-14`, // Fête nationale + `${y}-08-15`, // Assomption + `${y}-11-01`, // Toussaint + `${y}-11-11`, // Armistice 1918 + `${y}-12-25`, // Noël + ]; + // Dates variables pour 2026 + if (y === 2026) { + holidays.push('2026-04-06'); // Lundi de Pâques + holidays.push('2026-05-14'); // Ascension + holidays.push('2026-05-25'); // Lundi de Pentecôte + } + return holidays.includes(s); + }; + + const parseCirculationFlag = (value, defaultValue) => { + if (value === null || typeof value === 'undefined') { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value === 1; + } + if (typeof value === 'string') { + const s = value.trim().toLowerCase(); + if (['true', '1', 't', 'y', 'yes', 'oui'].includes(s)) { + return true; + } + if (['false', '0', 'f', 'n', 'no', 'non'].includes(s)) { + return false; + } + } + return defaultValue; + }; + + const circulatesOn = (h, d) => { + if (isPublicHoliday(d)) { + // On public holidays, circulation is determined SOLELY by `circule_jours_feries`. + // If the flag is true, it runs. Otherwise, it doesn't. + return parseCirculationFlag(h['circule_jours_feries'], false); + } + + // On a regular day, check the corresponding weekday column. + const col = weekdayNameForColumn(d); + if (!col) return true; // Should not happen, but permissive. + + // For regular weekdays, the default is to run unless specified otherwise. + return parseCirculationFlag(h[col], true); + }; + + // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* + // Calculer les travaux applicables et les substitutions + 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)); + }; + + // compute linkage and travaux applicability per horaire + const enriched = enrich.map(h => { + let linked = false; + let appliesToday = false; + let appliesTomorrow = false; + for (const t of travauxList) { + // Extraire identifiants de ligne (différentes variantes possibles) + 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; + + // Si le travail a une ligne attribuée, n'appliquer le travail QUE si la ligne correspond + // (si hLine est absent on considère qu'on ne peut pas appliquer ce travail à cet horaire) + if (typeof tLine !== 'undefined' && tLine !== null) { + if (typeof hLine === 'undefined' || hLine === null) { + // pas de ligne sur l'horaire -> ignorer ce travail pour cet horaire + continue; + } + if (String(tLine) !== String(hLine)) { + // lignes différentes -> ignorer ce travail pour cet horaire + 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; + + // Si l'horaire est explicitement listé dans travaux_substitutions pour CE travail, on le link + 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 }; + }); + + // Build today and tomorrow candidate lists + 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 travaux apply at this horaire today, hide regular horaires + 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; + + // hide regular horaires if travaux apply tomorrow during their time window + if (h._travauxAppliesTomorrow) return false; + + return true; + }); + + // final list: if there are any todayCandidates use them; otherwise use tomorrowCandidates + 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; + } + + // Construire un index local des perturbations à partir des données fraîchement récupérées + // Filtrer pour ne garder que les perturbations applicables aujourd'hui (éviter d'appliquer les perturbations d'hier) + + 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); + // si pDate fourni et ne correspond pas à la date de service -> ignorer + if (pDate && pDate !== serviceDateStr) return false; + // si fourche de dates fournie et la date de service n'est pas dans l'intervalle -> ignorer + if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; + // si jours_perturbation fourni -> vérifier que la date de service en fait partie + try { + const joursSet = getPerturbationJours(p); + if (joursSet && !joursSet.has(serviceDateStr)) return false; + } catch (e) { + // ignore parse errors and keep perturbation + } + return true; + } catch (e) { + return true; + } + }); + + const localPerturbMap = buildPerturbMap(filteredPerturb || [], parsedHoraires || []); + // Debug: afficher sommaire de l'index des perturbations + debug('localPerturbMap summary', { + horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length, + numberCount: Object.keys(localPerturbMap.byNumber || {}).length, + stopIdCount: Object.keys(localPerturbMap.byStopId || {}).length, + stopNameCount: Object.keys(localPerturbMap.byStopName || {}).length, + globalCount: (localPerturbMap.global || []).length, + sampleStopNames: Object.keys(localPerturbMap.byStopName || {}).slice(0,10) + }); + + // Annoter chaque horaire avec un statut calculé (synchronisé avec la BDD) + // déterminer la date de service utilisée pour ces finalList (aujourd'hui ou demain) + const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr); + + // 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)); + setPerturbations(filteredPerturb || []); + setTravaux(travauxData || []); + setTravauxSubstitutions(travauxSubstData || []); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Erreur Supabase horaires', err); + if (mounted) setError(err.message || String(err)); + } finally { + if (mounted) setLoading(false); + inFlight = false; + } + }; + + // Exécuter immédiatement, puis périodiquement pour synchroniser sans recharger + fetchData(); + const intervalMs = 15000; // 15 secondes par défaut + const intervalId = setInterval(fetchData, intervalMs); + + return () => { mounted = false; clearInterval(intervalId); }; + }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]); + + // Helpers pour perturbations (memoized plus haut) + + const [pageIndex, setPageIndex] = useState(0); + const [showAlternateStatus, setShowAlternateStatus] = useState(false); + + useEffect(() => { + const alternateInterval = setInterval(() => { + setShowAlternateStatus(prev => !prev); + }, 2000); + return () => clearInterval(alternateInterval); + }, []); + + const getPageBounds = useCallback((pIndex) => { + if (pIndex === 0) return { start: 0, end: 5 }; + const start = 5 + (pIndex - 1) * 8; + return { start, end: start + 8 }; + }, []); + + const totalPages = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); + + useEffect(() => { + const pageInterval = setInterval(() => { + setPageIndex((prev) => { + const tot = horaires.length <= 5 ? 1 : 1 + Math.ceil((horaires.length - 5) / 8); + if (tot <= 1) return 0; + return (prev + 1) % tot; + }); + }, 10000); // Change page every 10 seconds + return () => clearInterval(pageInterval); + }, [horaires.length]); + + const { start, end } = getPageBounds(pageIndex); + const displayedHoraires = horaires.slice(start, end); + + return ( +
+ {/* HEADER */} +
+
+
+ + + +
+
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)); + + // 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é'); + + let displayStatusText = "À l'heure"; + let delayVal = ''; + if (isDelayed) { + displayStatusText = status.label; + if (typeof status.delayMinutes === 'number') { + delayVal = `+${status.delayMinutes} min`; + displayStatusText = delayVal; + } + } + if (isSuppressed) displayStatusText = "Supprimé"; + + const isBus = op.includes('CAR') || op.includes('AUTOCAR') || op.includes('BUS') || (horaire.mode && horaire.mode.toLowerCase().includes('bus')); + + const isCompact = pageIndex > 0 && idx > 0; + const showCompactStatus = isCompact; + + return ( + +
  • +
    + {showCompactStatus && showAlternateStatus ? ( +
    + {isSuppressed ? ( + + + + + ) : isDelayed ? ( + + + + + ) : ( + + + + + )} + {displayStatusText} +
    + ) : ( + <> +
    {time}
    + {!isCompact && ( +
    + {isSuppressed ? ( + + + + + ) : isDelayed ? ( + + + + + ) : ( + + + + + )} + {displayStatusText} +
    + )} + + )} +
    + +
    +
    {op}
    + {!isCompact &&
    {number}
    } +
    + +
    +
    + {isBus && ( + + + + )} + +
    + {!isCompact && ( +
    + {viasStr ? ( + <> + via +
    +
    {viasStr}
    +
    + + ) : ( + direct + )} +
    + )} +
    + +
    + {isSuppressed ? ( + + + + + ) : showPlatform ? ( +
    + {!isBus &&
    Voie
    } +
    + {isBus ? ( + + + + ) : platform} +
    +
    + ) : null} +
    +
  • + {/* Séparateur pour toutes les lignes sauf la dernière. */} + {idx < displayedHoraires.length - 1 && ( +
    +
    +
    + )} + + ); + })} +
+
+ ); +} diff --git a/src/pages/afficheurs/afl/AFLDepartsQuais.css b/src/pages/afficheurs/afl/AFLDepartsQuais.css new file mode 100644 index 0000000..a57be6f --- /dev/null +++ b/src/pages/afficheurs/afl/AFLDepartsQuais.css @@ -0,0 +1,335 @@ +/* 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-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-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 --- */ +.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-quais-departs__header-left { + display: flex; + align-items: center; + gap: 1.2vw; +} + +.afl-quais-departs__header-title { + font-size: 5vh; + font-weight: 700; + letter-spacing: -0.02em; +} + +.afl-quais-departs__header-clock { + font-size: 5vh; + font-weight: 700; +} + +/* --- MAIN CONTENT --- */ +.afl-quais-departs__main { + flex: 1; + display: flex; + position: relative; + box-sizing: border-box; +} + +/* --- 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); +} + +.afl-quais-departs__time-status { + display: flex; + align-items: center; + gap: 1vw; + margin-bottom: 1.5vh; +} + +.afl-quais-departs__time { + font-size: 7.5vh; + font-weight: 800; + color: var(--accent-yellow); + line-height: 1; +} + +.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-quais-departs__status-pill svg { + margin-right: 0.5vw; +} + +.afl-quais-departs__status-pill--delayed { + background-color: #ff9e43; + color: #111; +} + +.afl-quais-departs__status-pill--suppressed { + background-color: #ff4c5b; + color: #111; +} + +.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-quais-departs__train-info { + font-size: 3vh; + color: var(--text-grey); + display: flex; + gap: 0.8vw; +} + +.afl-quais-departs__train-type { + font-weight: 800; +} + +.afl-quais-departs__train-number { + font-weight: 500; +} + +/* --- 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; +} + +.afl-quais-departs__stops-window { + width: 100%; + max-height: 100%; + overflow: hidden; + position: relative; +} + +.afl-quais-departs__stops-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + width: 100%; +} + +.afl-quais-departs__stop-item { + display: flex; + align-items: center; + height: 6.2vh; + position: relative; + box-sizing: border-box; +} + +/* 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; +} + +.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; +} + +/* Adjust line ends for first and last stops */ +.afl-quais-departs__stop-item.is-first .afl-quais-departs__stop-line { + top: 50%; +} + +.afl-quais-departs__stop-item.is-last .afl-quais-departs__stop-line { + bottom: 50%; +} + +/* 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; +} + +/* 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%; +} + +/* 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 new file mode 100644 index 0000000..f4de3bf --- /dev/null +++ b/src/pages/afficheurs/afl/AFLDepartsQuais.js @@ -0,0 +1,881 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import supabase from '../../../lib/supabase'; +import './AFLDepartsQuais.css'; + +// Helper utilitaire : format HH:MM sans secondes (ex: 15:57) +const formatTimeNoSeconds = (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}`; + } + const parts = String(v).split(':'); + if (parts.length >= 2) { + return `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`; + } + return v; +}; + +const AFLDepartsQuais = () => { + const [currentTime, setCurrentTime] = useState(new Date()); + const [nextDeparture, setNextDeparture] = 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 quaiParam = searchParams.get('quai') || searchParams.get('voie') || ''; + const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + + // Real-time clock update + useEffect(() => { + const timer = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + // --- Helpers pour perturbations, travaux, etc (portés de AFLDeparts.js) --- + 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) {} + } + } + 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 parseTimeHM = useCallback((s) => { + if (!s) return null; + if (typeof s === 'number') return s; + const m = String(s).match(/(\d{1,2})[h:](\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 buildPerturbMap = useCallback((list, allHorairesList) => { + 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(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 = []; + 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; + + 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 || na.includes(ns) || ns.includes(na); + } + 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 || ns.includes(na) || na.includes(ns); + } + 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; + } + } + } + + if (isSuppressed) return { label: 'supprimé', detail: '', delayMinutes: null }; + if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; + return { label: "à l'heure", detail: '', delayMinutes: null }; + }, [buildPerturbMap, getPerturbationJours, parsePerturbArretsObject, parseDelayMinutesFromArret, normalizeName, getStops]); + + const applyPerturbationsToHoraires = useCallback((list, perturbList, displayStationId, displayStationName, serviceDate, map) => { + const targetStationId = displayStationId || null; + const targetStationName = displayStationName || 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 || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); + horaire._stopsStatus = stopsStatus; + horaire._status = displayStatus; + horaire._displayTime = displayTime; + return horaire; + }); + }, [computeStatusForHoraireAtStop, getStops]); + + const fetchData = useCallback(async () => { + try { + const [{ data: horairesData }, { data: subData }, { data: perturbData }, { data: travauxData }] = await Promise.all([ + supabase.from('horaires').select('*'), + supabase.from('travaux_substitutions').select('*'), + supabase.from('perturbations').select('*'), + supabase.from('travaux').select('*') + ]); + + let parsedHoraires = (horairesData || []).map(h => { + try { + if (h && typeof h.stops === 'string') { + const parsed = JSON.parse(h.stops); + if (Array.isArray(parsed)) return { ...h, stops: parsed }; + } + } catch (e) {} + return h || {}; + }); + + const substitutions = subData || []; + const travauxList = travauxData || []; + + // Link substitutions + parsedHoraires = parsedHoraires.map(h => { + const subs = substitutions.filter(s => String(s.horaire_id) === String(h.id)); + if (subs.length > 0) { + return { ...h, _substitutions: subs }; + } + return h; + }); + + // Normaliser date + 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 = parsedHoraires.map(h => ({ ...h, _date: normalizeDate(h) })); + + // Time parsing & enrichment + 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 (gareDisplay && Array.isArray(stopsListForH)) { + const stop = stopsListForH.find(s => normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)); + if (stop && (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure; + } + const depHM = parseTimeHM(raw); + const displayTime = formatTimeNoSeconds(raw); + return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime }; + }); + + // Jours de circulation + 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 weekdayIndex = (d) => d.getDay(); + const weekdayNameForColumn = (d) => { + const i = weekdayIndex(d); + switch (i) { + case 1: return 'circule_lundi'; + case 2: return 'circule_mardi'; + case 3: return 'circule_mercredi'; + case 4: return 'circule_jeudi'; + case 5: return 'circule_vendredi'; + case 6: return 'circule_samedi'; + case 0: return 'circule_dimanche'; + default: return null; + } + }; + + const isPublicHoliday = (d) => { + const y = d.getFullYear(); + const m = d.getMonth() + 1; + const day = d.getDate(); + const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const holidays = [ + `${y}-01-01`, `${y}-05-01`, `${y}-05-08`, `${y}-07-14`, `${y}-08-15`, `${y}-11-01`, `${y}-11-11`, `${y}-12-25` + ]; + if (y === 2026) { + holidays.push('2026-04-06'); + holidays.push('2026-05-14'); + holidays.push('2026-05-25'); + } + return holidays.includes(s); + }; + + const parseCirculationFlag = (val, defaultVal) => { + if (val == null) return defaultVal; + if (typeof val === 'boolean') return val; + const s = String(val).trim().toLowerCase(); + if (['true', '1', 't', 'y', 'yes', 'oui'].includes(s)) return true; + if (['false', '0', 'f', 'n', 'no', 'non'].includes(s)) return false; + return defaultVal; + }; + + const circulatesOn = (h, d) => { + if (isPublicHoliday(d)) { + return parseCirculationFlag(h['circule_jours_feries'], false); + } + const col = weekdayNameForColumn(d); + if (!col) return true; + return parseCirculationFlag(h[col], true); + }; + + 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.start_date); + const df = parseDateOnly(t.date_fin || 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.start_time); + const endHM = parseTimeHM(t.heure_fin || 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 substitutions.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; + const hLine = h.ligne_id || h.ligne || h.line_id || h.line; + if (typeof tLine !== 'undefined' && tLine !== null) { + if (typeof hLine === 'undefined' || hLine === null || String(tLine) !== String(hLine)) { + continue; + } + } + const ref = t.horaire_id || t.horaire || t.numero_train || t.numero; + if (ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true; + const substForThisTravail = substitutions.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 }; + }); + + // Filter by station: keep departures from this station or stops at this station (excluding terminus) + let stationFiltered = enriched; + if (gareDisplay) { + const normalized = normalizeName(gareDisplay); + stationFiltered = enriched.filter(h => { + const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); + if (destName === normalized) return false; + + const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); + if (departName === normalized) return true; + + 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 || '').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; + }); + } + + // Candidate filters for Today and Tomorrow (Departure platform logic) + const todayCandidates = stationFiltered.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; + // Keep departures that occur in the future or up to 1 minute ago (departure window) + if (h._depHM < todayHM - 1) return false; + if (h._travauxAppliesToday) return false; + return true; + }); + + const tomorrowCandidates = stationFiltered.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.length > 0) { + finalList = todayCandidates.slice().sort((a, b) => a._depHM - b._depHM); + serviceDateStr = todayStr; + } else { + finalList = tomorrowCandidates.slice().sort((a, b) => a._depHM - b._depHM); + serviceDateStr = tomorrowStr; + } + + // Apply perturbations (delays, cancellations) + + 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; + const joursSet = getPerturbationJours(p); + if (joursSet && !joursSet.has(serviceDateStr)) return false; + return true; + } catch (e) { return true; } + }); + + 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; + if (gareDisplay && Array.isArray(stopsList)) { + 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 (candidatesOnPlatform.length > 0) { + setNextDeparture(candidatesOnPlatform[0]); + } else { + setNextDeparture(null); + } + setLoading(false); + } catch (err) { + console.error(err); + setLoading(false); + } + }, [applyPerturbationsToHoraires, buildPerturbMap, gareDisplay, getPerturbationJours, getStops, normalizeName, parseTimeHM, quaiParam]); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 15000); + return () => clearInterval(interval); + }, [fetchData]); + + if (loading) return
; + if (!nextDeparture) return ( +
+
+
+ + + + + + + +
Prochain départ
+
+
{formatTimeNoSeconds(currentTime)}
+
+
Aucun départ prévu
+
+ ); + + const stops = getStops(nextDeparture); + let filteredStops = []; + + 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); + } + } + } + + 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 }); + } + + // 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 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); + 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 shouldScroll = filteredStops.length > 10; + const scrollStyle = shouldScroll ? { + animation: `scrollStops ${filteredStops.length * 2.2}s linear infinite` + } : {}; + + return ( +
+ {/* Header */} +
+
+ + {/* Chevrons / motion lines */} + + {/* Train icon */} + + + + + +
Prochain départ
+
+
{formatTimeNoSeconds(currentTime)}
+
+ +
+ {/* Left Pane (Floating Card) */} +
+
+
{displayTime}
+
+ {isSuppressed ? ( + + + + + ) : isDelayed ? ( + + + + + ) : ( + + + + + )} + {displayStatusText} +
+
+
{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; + + return ( +
  • +
    +
    +
    +
    +
    {sname}
    +
  • + ); + })} + +
+
+
+
+ + {/* Symmetrical train outline under the stops list */} +
+
+ + + + + + + + + + +
+
+ + {platformStr && ( +
+
Voie
+
{platformStr}
+
+ )} +
+ ); +}; + +export default AFLDepartsQuais; diff --git a/src/pages/afficheurs/classiques/ClassiquesArrivees.css b/src/pages/afficheurs/classiques/ClassiquesArrivees.css index 1f81e6c..b8c916f 100644 --- a/src/pages/afficheurs/classiques/ClassiquesArrivees.css +++ b/src/pages/afficheurs/classiques/ClassiquesArrivees.css @@ -1,17 +1,6 @@ -/* Copié depuis ClassiquesDeparts.css — styles identiques pour les arrivées */ - -:where(.classiques-arrivees-container, .aff-quais__container--arrivees) { - /* variables locales aux composants Arrivées (ne pas définir sur :root) */ - --green-dark: #2d522c; /* bande foncée */ - --green: #38723c; /* bande claire */ - --accent-yellow: #e1c14c; /* heure */ - --white: #f3f3f3; - --rows: 9; /* 9 horaires visibles + 1 footer */ - --footer-height: 100px; /* hauteur de la dernière 'ligne' servant de footer */ - --watermark-green: rgb(75, 154, 84); -} - -/* Déclaration des fontes Achemine (dans public/fonts/) */ +/* ══════════════════════════════════════════════════════════════════ + Fontes Achemine + ══════════════════════════════════════════════════════════════════ */ @font-face { font-family: 'Achemine'; src: url('/public/fonts/achemine_regular.ttf') format('truetype'); @@ -34,365 +23,400 @@ font-display: swap; } -.classiques-arrivees-container{ +/* ══════════════════════════════════════════════════════════════════ + ANIMATIONS + ══════════════════════════════════════════════════════════════════ */ + +/* Info cause perturbation — slide-in de droite vers la gauche */ +@keyframes infoWrapperGrow { + from { max-width: 0; } + to { max-width: 30vw; } +} + +@keyframes infoSlideIn { + from { transform: translateX(100%); opacity: 0; } + 40% { opacity: 1; } + to { transform: translateX(0%); opacity: 1; } +} + +/* ══════════════════════════════════════════════════════════════════ + CONTENEUR PRINCIPAL + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__container { + /* Couleurs inspirées du vrai afficheur SNCF */ + --yellow: #FFCC00; + --blue-bg: #1e4a30; + --blue-odd: #216b3e; + --blue-even: #16552a; + --text-white: #ffffff; + --text-muted: #7EE0A0; + position: relative; - width: 100%; + width: 100vw; height: 100vh; - /* autoriser l'affichage des boîtes agrandies qui débordent des lignes */ - overflow: visible; - font-family: "Achemine", "Helvetica Neue", Arial, sans-serif; - background: var(--green-dark); - /* plus de padding-bottom : le footer sera superposé en position absolute */ -} - -.station-box-arrivees{ - position: absolute; - left: 18px; - top: 12px; - color: #ffffff; - background: rgba(0,0,0,0.08); - padding: 6px 10px; - border-radius: 6px; - font-weight: 700; - z-index: 40; -} - -.classiques-arrivees-container{ - border-bottom-right-radius: 10px; - border-bottom-left-radius: 4px; -} - -.arrivees-list{ - list-style: none; - margin: 0; - padding: 0; - /* La liste occupe toute la hauteur de l'écran; le footer sera affiché - en position absolue par-dessus la partie inférieure. */ - height: 100vh; - display: flex; - flex-direction: column; -} - -.arrivee-row{ - display: flex; - /* laisser les colonnes occuper toute la hauteur de la ligne, puis centrer leur contenu */ - align-items: stretch; - box-sizing: border-box; - padding: 0 1.2vw; - /* Répartit l'espace en var(--rows) parts ; on utilise 100% / var(--rows) pour que - 9 lignes + 1 footer occupent l'écran de façon uniforme. */ - flex: 0 0 calc(100% / var(--rows)); - min-height: 50px; /* garde une bonne hauteur sur petits écrans */ - max-height: 120px; - position: relative; /* pour positionner la .platform-box en absolu */ - overflow: visible; /* laisser la platform-box déborder si agrandie */ -} - -.arrivee-row:nth-child(odd){ - background: var(--green); -} -.arrivee-row:nth-child(even){ - background: var(--green-dark); -} - -/* Cacher la dernière ligne qui sera sous le footer */ -/* ne plus cacher la dernière ligne — on laisse le footer la recouvrir visuellement */ - -.col-logo-arrivees{ - max-width: 100px; - display:flex; - /* organiser verticalement les éléments et les centrer verticalement */ - flex-direction: column; - justify-content: center; /* centre verticalement le contenu (logo + meta) */ - align-items: flex-start; /* garder l'alignement horizontal à gauche */ - flex: 0 0 10%; /* largeur fixe pour se comporter comme une colonne de tableau */ -} -.logo-box-arrivees{ - /* pastille/logo : ne pas forcer la hauteur pour permettre l'empilement vertical */ - height: auto; - width: 100px; - display:flex; - align-items:flex-start; - justify-content:center; - font-weight:800; - font-size: clamp(14px, 1.6vw, 20px); - box-sizing: border-box; - flex: 0 0 auto; - align-self: flex-start; /* centrer la pastille dans la colonne */ -} - -/* Logo SVG chargé depuis public/type */ -.op-logo-arrivees{ - max-width: 90%; - max-height: 100%; - height: auto; -} - -/* Texte à l'intérieur de la pastille/logo (quai) */ -.logo-text-arrivees{ - text-align: center; - /* taille plus grande, responsive ; s'assure qu'elle reste lisible sur petits et grands écrans */ - font-size: 20px; - line-height: 1; - letter-spacing: 0.5px; -} - - -.col-time-arrivees{ - max-width: 15%; - padding-left: 20px; - display:flex; - /* colonne verticale : centrer verticalement l'heure */ - flex-direction: column; - align-items: center; - justify-content: center; - flex: 0 0 18%; /* largeur fixe pour alignement table-like */ -} -.time-arrivees{ - color: var(--accent-yellow); - font-weight:850; - /* tailles responsives similaires à l'image */ - font-size: clamp(26px, 7.5vw, 70px); - line-height: 1; - display: flex; - align-items: baseline; /* centre le chiffre verticalement */ - height: auto; - width: 100%; /* occupe toute la colonne pour être centré */ - text-align: center; -} - -.col-destination-arrivees{ - flex: 1 1 auto; /* colonne flexible qui prend l'espace restant */ - display: flex; - /* colonne verticale : centrer verticalement la destination */ - flex-direction: column; - justify-content: center; - align-items: flex-start; /* garder le texte aligné à gauche */ -} -.destination-arrivees{ - color: var(--white); - font-weight: 500; - font-size: clamp(28px, 8vw, 80px); - white-space: nowrap; - text-overflow: ellipsis; overflow: hidden; + background: var(--blue-bg); + font-family: "Achemine", "Arial Narrow", Arial, sans-serif; } -.col-platform-arrivees{ - /* La colonne laisse la boîte gérer sa taille ; on aligne la boîte à droite - et l'on centre verticalement pour qu'elle suive la hauteur de la ligne. */ - display:flex; - flex: 0 0 8%; - align-items: center; - justify-content: flex-end; - padding-right: 0.5vw; - box-sizing: border-box; -} -.platform-box-arrivees{ - /* Boîte quai : garder un format carré mais ne pas forcer l'étirement vertical */ - height: 90px; /* limite maximale, garde l'aspect visuel */ - width: 90px; - aspect-ratio: 1 / 1; /* carré strict */ - display:flex; - align-items:center; - justify-content:center; - background: transparent; /* intérieur transparent pour montrer la couleur de la ligne */ - border-radius: 12px; - box-sizing: border-box; - border: 4px solid #ffffff; /* bord blanc épais */ - padding: 8px; /* espace intérieur pour éloigner le texte du bord */ - flex: 0 0 auto; - align-self: center; -} -.platform-text-arrivees{ - color: #ffffff; - font-weight: 900; - /* réduire légèrement la taille du texte afin qu'il ne touche pas la bordure */ - font-size: clamp(60px, 2.6vw, 100px); - line-height:1; - letter-spacing: 0.8px; -} -/* removed duplicated/corrupted block */ - -.col-train-type-arrivees{ - max-width: 9%; - display:flex; - /* colonne verticale : centrer verticalement l'heure */ - flex-direction: column; - align-items: stretch; - justify-content: center; - flex: 0 0 18%; /* largeur fixe pour alignement table-like */ -} -.train-type-box-arrivees{ - height: auto; - aspect-ratio: 1 / 1; - display:flex; - align-items:center; - justify-content:center; - background: transparent; - border-radius: 10px; - box-sizing: border-box; -} -.train-type-fallback-arrivees{ text-align:center; } - - -.train-meta-arrivees{ - flex-direction:column; - align-items:flex-start; - /* centrer verticalement sans forcer la hauteur (pour permettre l'empilement) */ - justify-content: center; - color: #ffffff; - height: auto; - flex: 0 0 auto; -} -.train-meta-arrivees .op-name-arrivees{ - font-weight:900; - font-size:1.50rem; /* plus visible */ - padding-left: 20px; - line-height:1; - justify-content: flex-start; - text-align: left; /* aligner à gauche dans la colonne */ -} -.train-meta-arrivees .op-number-arrivees{ - font-weight:500; - font-size:1.25rem; - padding-left: 20px; - text-align: left; /* aligner à gauche */ - margin: 0 0 2px 0; -} - -.train-meta-arrivees.status-delayed-arrivees .op-name-arrivees, -.train-meta-arrivees.status-delayed-arrivees .op-number-arrivees, -.train-meta-arrivees.status-suppressed-arrivees .op-name-arrivees, -.train-meta-arrivees.status-suppressed-arrivees .op-number-arrivees{ - color: var(--accent-yellow); -} - -/* Si on veut un alignement plus spécifique quand train-meta apparaît dans la colonne de type */ -.col-train-type-arrivees .train-meta-arrivees{ align-items: flex-start; justify-content: center; } -.col-train-type-arrivees .train-meta-arrivees .op-name-arrivees, -.col-train-type-arrivees .train-meta-arrivees .op-number-arrivees{ text-align: left; } - -.watermark-arrivees{ +/* ══════════════════════════════════════════════════════════════════ + WATERMARK "arrivées" — pivotée à 90°, fixée à droite + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__watermark { position: absolute; - /* placer strictement à droite et faire déborder le watermark en dehors de l'afficheur */ - right: -6vw; /* décalage vers l'extérieur pour qu'il soit strictement à droite */ + right: -9vh; top: 50%; transform: translateY(-50%) rotate(-90deg); transform-origin: center; - font-size: 10.8vw; - color: var(--watermark-green); - font-weight:800; - letter-spacing: 6px; + /* Taille géante, très visible comme dans l'image */ + font-size: 23vh; + color: #216b3e; + font-weight: 900; + letter-spacing: 0.05em; pointer-events: none; user-select: none; - /* s'assurer qu'il soit au-dessus des lignes */ - z-index: 45; + z-index: 1; + white-space: nowrap; + opacity: 0.3; /* Opacité diminuée */ } -.helpline-arrivees{ - position: absolute; - right: 18px; - top: 12px; - width:80px; - height:80px; - border-radius:6px; - border:3px solid #fff; - color:#fff; - display:flex; - align-items:center; - justify-content:center; - font-weight:900; +/* ══════════════════════════════════════════════════════════════════ + LISTE STATIQUE — 10 lignes sur exactement 100vh + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__list { + list-style: none; + margin: 0; + padding: 0; + height: 100vh; + display: flex; + flex-direction: column; + position: relative; + z-index: 2; } -.clock-container-arrivees{ - position: relative; /* contenu positionné par le footer */ - pointer-events: none; - z-index: 30; /* au-dessus du footer */ - display: inline-flex; - align-items: center; - height: 100%; /* prendra la hauteur de la ligne footer */ +/* ══════════════════════════════════════════════════════════════════ + MODE DÉFILEMENT — conteneur + track animé + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__scroll-container { + height: 100vh; + overflow: hidden; + position: relative; + z-index: 2; } -.clock-box-arrivees{ - display: inline-flex; + +/* Le track contient 2× les données pour une boucle seamless */ +.aff-classiques-arrivees__scroll-track { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +/* ══════════════════════════════════════════════════════════════════ + ROW — chaque ligne occupe 1/10 de 100vh + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__row { + display: flex; align-items: center; - justify-content: center; - background: var(--green-dark); /* fond foncé interne comme sur la maquette */ - border-radius: 10px; - padding: 4px 8px; /* réduire padding pour diminuer légèrement la taille globale */ - border: 6px solid #ffffff; /* bordure un peu moins épaisse */ - box-shadow: 0 2px 0 rgba(0,0,0,0.12); + flex: 0 0 calc(100vh / 10); + height: calc(100vh / 10); + min-height: 0; + padding: 0; box-sizing: border-box; overflow: hidden; - /* hauteur adaptée pour rentrer exactement dans la ligne footer */ - height: calc(var(--footer-height) - 12px); - min-height: 34px; } -.afficheur-footer-arrivees{ - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: var(--footer-height); +/* Alternance de tons bleus (comme l'image) */ +.aff-classiques-arrivees__row:nth-child(odd) { background: var(--blue-odd); } +.aff-classiques-arrivees__row:nth-child(even) { background: var(--blue-even); } + +.aff-classiques-arrivees__row--empty { + flex-direction: column; + justify-content: center; + align-items: center; + color: var(--text-white); + font-size: 3vh; + text-align: center; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 1 — Logo opérateur (~9 % largeur) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__col-logo { + width: 9vw; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; - pointer-events: none; - z-index: 20; /* en dessous de l'horloge */ -} -.afficheur-footer-arrivees .footer-inner-arrivees{ - width: 100%; height: 100%; - position: relative; + padding: 0 0.4vw; + box-sizing: border-box; +} + +.aff-classiques-arrivees__logo-box { + width: 100%; + /* Logo prend ~65% de la hauteur de la ligne */ + height: 6vh; display: flex; - align-items: center; /* centre verticalement le contenu (la clock) */ - justify-content: flex-end; /* place la clock à droite */ - padding-right: 12px; /* espace entre le bord et la clock pour le coin arrondi */ - /* léger dégradé pour donner l'effet de bande plus claire en haut */ - background: linear-gradient(to bottom, rgba(11,123,180,0.18) 0%, var(--green-dark) 30%); - box-shadow: inset 0 6px 0 rgba(0,0,0,0.08); - /* arrondir côté droit comme sur la maquette */ - + align-items: center; + justify-content: center; } -/* légère ligne (accent) en haut du footer pour séparer visuellement */ -.afficheur-footer-arrivees .footer-inner-arrivees::before{ - content: ''; +.aff-classiques-arrivees__op-logo { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.aff-classiques-arrivees__logo-text { + color: var(--text-white); + font-weight: 700; + font-size: 2vh; + text-align: center; + word-break: break-word; + line-height: 1.1; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 2 — Type + Numéro OU Statut (~11 % largeur) + Alterne toutes les 4 secondes + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__col-meta { + width: 11vw; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 0.5vw 0 0; + box-sizing: border-box; + gap: 0.05vh; + overflow: hidden; + text-align: center; +} + +/* Vue normale — type (ex: "MOBIGO") */ +.aff-classiques-arrivees__train-type { + font-size: 2.2vh; + color: var(--text-white); + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +/* Vue normale — numéro de sillon (ex: "894251") */ +.aff-classiques-arrivees__train-number { + font-size: 2.2vh; + color: var(--text-white); + font-weight: 400; + line-height: 1.3; + white-space: nowrap; +} + +/* Vue statut — à l'heure */ +.aff-classiques-arrivees__status-ontime { + font-size: 2.2vh; + color: var(--text-white); + font-weight: 400; + line-height: 1.3; + white-space: nowrap; +} + +/* Vue statut — retardé */ +.aff-classiques-arrivees__status-delayed { + display: flex; + flex-direction: column; + gap: 0; + line-height: 1.15; +} + +.aff-classiques-arrivees__status-label { + font-size: 2.2vh; + color: var(--yellow); + font-weight: 500; + white-space: nowrap; +} + +.aff-classiques-arrivees__status-value { + font-size: 2.2vh; + color: var(--yellow); + font-weight: 700; + white-space: nowrap; +} + +/* Vue statut — supprimé */ +.aff-classiques-arrivees__status-suppressed { + font-size: 2.2vh; + color: var(--yellow); + font-weight: 700; + white-space: nowrap; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 3 — Heure (~15 % largeur) + Grande, jaune vif (#FFCC00), bold — comme dans l'image + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__col-time { + width: 15vw; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-start; + height: 100%; + padding: 0 0.3vw 0 0; + box-sizing: border-box; +} + +.aff-classiques-arrivees__time { + /* Jaune vif exactement comme dans la vidéo/image */ + color: var(--yellow); + font-weight: 700; + /* Grande taille — occupe ~65% de la hauteur de ligne */ + font-size: 6.5vh; + line-height: 1; + white-space: nowrap; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 4 — Destination + Info cause (flex:1 — prend tout le reste) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__col-origin { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; + overflow: hidden; + height: 100%; + padding-right: 0.5vw; +} + +/* Texte destination — blanc, bold, très grand */ +.aff-classiques-arrivees__destination { + color: var(--text-white); + font-weight: 700; + font-size: 6.5vh; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; + min-width: 0; +} + +/* ─── Wrapper animé cause perturbation ────────────────────────── */ +.aff-classiques-arrivees__info-wrapper { + flex-shrink: 0; + overflow: hidden; + max-width: 0; + animation: infoWrapperGrow 0.85s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.aff-classiques-arrivees__info-inner { + display: flex; + align-items: center; + transform: translateX(100%); + animation: infoSlideIn 0.85s cubic-bezier(0.4, 0, 0.2, 1) forwards; + white-space: nowrap; +} + +.aff-classiques-arrivees__separator { + color: var(--text-white); + font-size: 4.5vh; + font-weight: 200; + line-height: 1; + flex-shrink: 0; + padding: 0 0.9vw; +} + +.aff-classiques-arrivees__info-text { + color: var(--text-white); + font-size: 1.9vh; + font-weight: 400; + white-space: normal; + overflow: hidden; + line-height: 1.25; + max-width: 22vw; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 5 — Quai OU icône bus (~6.5 % largeur) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__col-platform { + width: 6.5vw; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + box-sizing: border-box; + padding: 0.5vh 0.4vw; +} + +/* Boîte quai — bordure blanche */ +.aff-classiques-arrivees__platform-box { + width: 6vh; + height: 6vh; + border: 2.5px solid rgba(255, 255, 255, 0.88); + display: flex; + align-items: center; + justify-content: center; + border-radius: 7px; + background: transparent; + box-sizing: border-box; +} + +.aff-classiques-arrivees__platform-text { + color: var(--text-white); + font-weight: 700; + font-size: 4.5vh; + line-height: 1; +} + +/* ══════════════════════════════════════════════════════════════════ + HORLOGE — superposée en bas à droite (z-index élevé) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-arrivees__footer { position: absolute; - left: 0; - right: 0; - top: 0; - height: 8px; - background: rgba(255,255,255,0.03); + right: 1.2vw; + bottom: 0.8vh; + z-index: 30; pointer-events: none; - border-top-right-radius: 12px; } -.clock-main-arrivees{ - color: #ffffff; - font-weight: 800; - /* légèrement plus petit pour tenir dans le footer */ - font-size: clamp(28px, 4.2vw, 60px); - line-height: 1; - font-family: "Achemine", "Helvetica Neue", Arial, sans-serif; - margin-right: 6px; + +.aff-classiques-arrivees__clock-container { + display: flex; + align-items: center; } -.clock-seconds-arrivees{ - color: var(--accent-yellow); + +.aff-classiques-arrivees__clock-box { + border: 2.5px solid rgba(255, 255, 255, 0.88); + background: rgba(0, 51, 102, 0.93); + border-radius: 8px; + padding: 0.4vh 1.1vw; + display: flex; + align-items: baseline; + gap: 0.5vw; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.65); +} + +.aff-classiques-arrivees__clock-main { + color: var(--text-white); font-weight: 800; - font-size: clamp(16px, 2.8vw, 36px); + font-size: 4.4vh; line-height: 1; } -/* Ajustements pour petits écrans */ -@media (max-width: 700px){ - .col-logo-arrivees{width: 18%;} - .col-time-arrivees{width: 24%;} - .watermark-arrivees{display:none} +.aff-classiques-arrivees__clock-seconds { + color: var(--yellow); + font-weight: 800; + font-size: 2.7vh; + line-height: 1; } - - - - - +/* masqué */ +.aff-classiques-arrivees__station-box { display: none; } \ No newline at end of file diff --git a/src/pages/afficheurs/classiques/ClassiquesArrivees.js b/src/pages/afficheurs/classiques/ClassiquesArrivees.js index 9190aac..60c0c4b 100644 --- a/src/pages/afficheurs/classiques/ClassiquesArrivees.js +++ b/src/pages/afficheurs/classiques/ClassiquesArrivees.js @@ -1,75 +1,59 @@ import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesArrivees.css'; import supabase from '../../../lib/supabase'; +import Logo from '../../../components/Logo'; +import MarqueeText from '../../../components/MarqueeText'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (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}h${mm}`; + const hh = String(tryDate.getHours()).padStart(2, '0'); + const mm = String(tryDate.getMinutes()).padStart(2, '0'); + return `${hh}h${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')}h${m[2]}`; - const m2 = v.match(/T(\d{2}):(\d{2})/); - if (m2) return `${m2[1]}h${m2[2]}`; - return v.slice(0,5).replace(':','h'); + const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/); + if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`; + const m2 = v.match(/T(\d{2}):(\d{2})/); + if (m2) return `${m2[1]}h${m2[2]}`; + return v.slice(0,5).replace(':','h'); } return String(v).slice(0,5).replace(':','h'); }; -function Logo({op}){ - const mapping = { - 'TGV inOUI': 'logo-inoui.svg', - 'TGV inOUI ': 'logo-inoui.svg', - 'TGV': 'logo-tgv.svg', - 'TER': 'sncf-voyageurs-logo.png', - 'MOBIGO': 'logo-mobigo.svg', - 'TGV Lyria': 'logo-lyria.svg', - 'TGV Lyria ': 'logo-lyria.svg', - }; - const defaultFileFromOp = (name) => { - if (!name) return null; - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); - return `logo-${slug}.svg`; - }; - const fileName = mapping[op] || mapping[op && op.split && op.split(' ')[0]] || defaultFileFromOp(op); - const [hasImg, setHasImg] = React.useState(true); - return ( -
- {hasImg && fileName ? ( - {op} setHasImg(false)} - /> - ) : ( -
{(op||'').split(' ')[0]}
- )} -
- ); -} +const isSubstitution = (h) => { + if (!h) return false; + if (h.substitution_disponible === true) return true; + const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true; + const subStr = (typeof h.substitution === 'string' && ['true','1','yes','y','t'].includes(h.substitution.toLowerCase())); + const typeMatch = h.type_train && /subst|substitut|remplac/i.test(String(h.type_train)); + const commentMatch = h.comment && /subst|substitut|remplac/i.test(String(h.comment)); + return Boolean(subFlag || subStr || typeMatch || commentMatch); +}; + +// (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) function Clock(){ const [now, setNow] = useState(new Date()); useEffect(() => { - const id = setInterval(() => setNow(new Date()), 1000); - return () => clearInterval(id); + 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}
-
-
+
+
+
{hh}:{mm}
+
{ss}
+
+
); } @@ -79,13 +63,19 @@ export default function ClassiquesArrivees(){ const gareParam = params.get('gare'); // support pour ?gare_id=123 ou ?gareId=123 const gareIdParam = params.get('gare_id') || params.get('gareId'); + // gérer les +, décoder et remplacer les tirets par des espaces const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + // Mode défilement (marquee vertical infini) si ?defilement=true dans l'URL + const defilementMode = params.get('defilement') === 'true'; + // 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]); + const debug = useCallback((...args) => { if (debugEnabled) console.debug(...args); }, [debugEnabled]); - // État pour alterner l'affichage toutes les 3 secondes + // Alterner toutes les 2 secondes entre type+numéro et statut const [showStatus, setShowStatus] = useState(false); + // Afficher la cause de perturbation 5s, cacher 5s (cycle indépendant) + const [showInfoCause, setShowInfoCause] = useState(true); // État pour l'heure actuelle, mise à jour chaque seconde pour la logique d'affichage du quai const [currentTime, setCurrentTime] = useState(new Date()); @@ -95,12 +85,7 @@ export default function ClassiquesArrivees(){ return () => clearInterval(id); }, []); - useEffect(() => { - const id = setInterval(() => setShowStatus(s => !s), 3000); - return () => clearInterval(id); - }, []); - - // Données + // Données depuis Supabase const [horaires, setHoraires] = useState([]); const [allHoraires, setAllHoraires] = useState([]); const [perturbations, setPerturbations] = useState([]); @@ -109,181 +94,344 @@ export default function ClassiquesArrivees(){ const [, setTravaux] = useState([]); const [, setLoading] = useState(false); const [, setError] = useState(null); + // debug des données brutes renvoyées par Supabase const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] }); + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent + useEffect(() => { + let mounted = true; + const fetchStationType = async () => { + try { + let fetchedType = 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; + } 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; + }); + fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + } + } + if (mounted) { + setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + } + } catch (e) { /* ignore */ } + }; + if (supabase) { + fetchStationType(); + } + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + + // --- Helpers pour perturbations (définis ici pour éviter use-before-define dans useEffect) --- 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(); } + 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 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); + // some payloads wrap { arrets: [...], jours_perturbation: [...] } + if (parsed && typeof parsed === 'object') { + if (Array.isArray(parsed.arrets)) return parsed.arrets; + // if parsed itself is an array-like object, return it + return parsed; + } + return []; + } catch (e) { return []; } }, []); + // Retourne la liste d'arrêts d'un horaire, supporte plusieurs noms de champs (stops, gares_desservies...) 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 []; + 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; + // parfois la colonne est stockée sous forme de chaîne JSON + 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 []; }, []); + // parse perturbations_arrets as object to extract both arrets and jours_perturbation 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 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 }; + } }, []); + // extrait un set de dates 'YYYY-MM-DD' depuis p.jours_perturbation (top-level) ou depuis perturbations_arrets.jours_perturbation 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; + if (!p) return null; + // check top-level field first + 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); + } + } + // fallback: inspect perturbations_arrets object + 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; + 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 formatTimeFromHM = useCallback((totalMinutes) => { - if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; - const m = ((totalMinutes % 1440) + 1440) % 1440; - const hh = String(Math.floor(m / 60)).padStart(2, '0'); - const mm = String(m % 60).padStart(2, '0'); - return `${hh}h${mm}`; - }, []); - - // parse HH:mm or ISO into minutes since midnight (utilisable dans le rendu) + // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant) const parseTimeHM = useCallback((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; + 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; }, []); // retourne le threshold (en minutes) pour afficher le quai selon le type de gare - const thresholdForStationType = useCallback((type) => { - if (!type) return 0; - const t = String(type).trim().toLowerCase(); - if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures - if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes - return 0; + const thresholdForStationType = useCallback((typeGare) => { + const tG = String(typeGare || '').trim().toLowerCase(); + + // Vérification du type de gare + if (tG === 'interurbaine' || tG.includes('interurb')) { + return 360; // 6 heures + } + if (tG === 'ville' || tG.includes('ville')) { + return 30; // 30 minutes + } + + // par défaut, 30 minutes + return 30; }, []); - 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); } - }); + const formatTimeFromHM = useCallback((totalMinutes) => { + if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; + const m = ((totalMinutes % 1440) + 1440) % 1440; + const hh = String(Math.floor(m / 60)).padStart(2, '0'); + const mm = String(m % 60).padStart(2, '0'); + return `${hh}h${mm}`; + }, []); - 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; + // buildPerturbMap now accepts an optional list of horaires to link horaire_id -> numero_train + 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 we have horairesList, propagate horaire_id perturbations to byNumber using the matching horaire's numero_train + 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] || []; + // append all perturbations that reference this horaire id + (idx.byHoraire[hid] || []).forEach(p => idx.byNumber[numKey].push(p)); + } + } + } + } + } catch (e) { + // ignore mapping errors + } + } + + return idx; }, [perturbations, allHoraires, safeParseArrets, normalizeName]); + const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => { - const res = []; + const res = []; + const map = idx || buildPerturbMap(); + try { debug('findPerturbsForHoraireAndStop', { horaireId: horaire && (horaire.id || horaire.horaire_id), numero: horaire && (horaire.numero_train || horaire.numero), stationId, stationName, counts: { byHoraire: Object.keys(map.byHoraire||{}).length, byNumber: Object.keys(map.byNumber||{}).length } }); } catch (e) {} + 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 (hId != null) { + const kh = String(hId); + if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); + } + if (number != null) { + const kn = String(number); + if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); + } + if (stationId != null) { + const ks = String(stationId); + if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); + } + if (stationName && map.byStopName) { + const key = normalizeName(stationName); + if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation)); + + // recherche permissive : accepter les clés qui contiennent ou sont contenues par le needle + try { + const needle = key; + for (const existingKey of Object.keys(map.byStopName || {})) { + if (existingKey === needle) continue; + if (existingKey.includes(needle) || needle.includes(existingKey)) { + map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); + } + } + } catch (e) { + // ignore + } + } + return Array.from(new Set(res)); + }, [buildPerturbMap, normalizeName, debug]); + + + // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing + // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing + 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 (hId != null) { - const kh = String(hId); - if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); - } - if (number != null) { - const kn = String(number); - if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); - } - if (stationId != null) { - const ks = String(stationId); - if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); - } - if (stationName && map.byStopName) { - const key = normalizeName(stationName); - if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation)); - try { - const needle = key; - for (const existingKey of Object.keys(map.byStopName || {})) { - if (existingKey === needle) continue; - if (existingKey.includes(needle) || needle.includes(existingKey)) { - map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); + + 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)); } } - } catch (e) {} + } + 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); + } } - return Array.from(new Set(res)); - }, [buildPerturbMap, normalizeName]); - const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { - const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; - 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 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; - for (const p of found) { + let isSuppressed = false; + let causeDetails = ''; + + for (const p of allPerturbs) { try { if (horaireYmd) { - const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); + 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; @@ -292,495 +440,834 @@ export default function ClassiquesArrivees(){ } catch (e) {} try { const joursSet = getPerturbationJours(p); - if (joursSet && horaireYmd) { if (!joursSet.has(horaireYmd)) continue; } + 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 || []; - let matched = false; - for (const a of 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 ns = normalizeName(stationName || ''); - const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); - if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { - matched = true; + + 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)) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { + 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 (!matched) { - 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; - if (appliesByRef || appliesByNumber || isGlobal) { - const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); - if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { - const d = 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; + } + if (p.cause_details) { + causeDetails = p.cause_details; } } } - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; - return { label: "à l'heure", detail: '', delayMinutes: null }; - }, [findPerturbsForHoraireAndStop, parsePerturbArretsObject, getPerturbationJours, parseDelayMinutesFromArret, normalizeName]); - const applyPerturbationsToHoraires = useCallback((list = [], perturbList = perturbations, displayStationId = null, displayStationName = null, serviceDate = null) => { - const map = buildPerturbMap(perturbList || []); + 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]); + + // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée + const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { + const map = buildPerturbMap(perturbList || []); + // fallback to global display station context 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 }); - } - // compute display time using delay at the display station (arrivée) - const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); - let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || ''); - if (displayStatus && typeof displayStatus.delayMinutes === 'number' && !isNaN(displayStatus.delayMinutes)) { - if (typeof horaire._arrHM === 'number') { - displayTime = formatTimeFromHM(horaire._arrHM + displayStatus.delayMinutes); - } else { - const tryParse = (s) => { - if (!s) return null; - 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 base = tryParse(horaire._rawArrTime || horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || ''); - if (typeof base === 'number') displayTime = formatTimeFromHM(base + displayStatus.delayMinutes); - } - } - horaire._stopsStatus = stopsStatus; - // prefer platform from stop data (arrivée) if available — check several possible keys - 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 || ns.includes(target) || target.includes(ns))); - }) || null; - horaire._status = displayStatus; - horaire._displayTime = displayTime; - // arrival platform keys preferred, then fallback to generic platform - horaire._displayPlatform = (stopForStation && (stopForStation.quai_arrivee || stopForStation.platform_arrivee || stopForStation.quay_arrivee || stopForStation.arrival_platform || stopForStation.quai || stopForStation.platform || stopForStation.quay)) || horaire.arrival_platform || horaire.platform || ''; - return horaire; - }); - }, [buildPerturbMap, computeStatusForHoraireAtStop, formatTimeFromHM, perturbations, gareDisplay, gareIdParam, getStops, normalizeName]); + + 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 }); + } + + // compute display time using delay at the display station if any + const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); + let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); + horaire._stopsStatus = stopsStatus; + // prefer platform from stop data (quai) if available + 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; + // utiliser quai en priorité + horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay)) || horaire.quai || horaire.quai_reel || horaire.platform || ''; + return horaire; + }); + }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); + useEffect(() => { + // Alternance toutes les 4 secondes (type+numéro ↔ statut) + const id = setInterval(() => setShowStatus(s => !s), 4000); + return () => clearInterval(id); + }, []); + + // Cycle 5s visible / 5s caché pour la cause de perturbation + useEffect(() => { + let timeoutId; + const scheduleCycle = (currentlyVisible) => { + timeoutId = setTimeout(() => { + setShowInfoCause(!currentlyVisible); + scheduleCycle(!currentlyVisible); + }, 5000); + }; + setShowInfoCause(true); + scheduleCycle(true); + return () => clearTimeout(timeoutId); + }, []); + + // Récupération Supabase — maintenant avec polling pour synchroniser sans recharger la page useEffect(() => { let mounted = true; - let inFlight = false; + let inFlight = false; // empêche les fetchs concurrents + const fetchData = async () => { - if (!mounted) return; - if (inFlight) return; - inFlight = true; - if (!supabase) { - setError('Supabase non initialisé — vérifiez les variables d\'environnement'); - setLoading(false); - inFlight = false; - return; - } - setLoading(true); - setError(null); - try { - const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ - supabase.from('horaires').select('id, *'), - supabase.from('perturbations').select('*'), - supabase.from('travaux').select('*') - ]); - if (!mounted) { inFlight = false; return; } - if (hError || pError || tError) { - setError((hError || pError || tError).message || String(hError || pError || tError)); - setLoading(false); - inFlight = false; - return; - } - debug('Supabase horaires fetched', { count: (horairesData || []).length }); - if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) }); - let parsedHoraires = (horairesData || []).map(h => h || {}); - parsedHoraires = parsedHoraires.map(h => { - try { - if (h && typeof h.stops === 'string') { - const parsed = JSON.parse(h.stops); - if (Array.isArray(parsed)) return { ...h, stops: parsed }; - } - } catch (e) {} - return h; - }); + if (!mounted) return; + if (inFlight) return; // éviter les appels simultanés si le fetch précédent n'est pas terminé + inFlight = true; + if (!supabase) { + // Supabase non initialisé — avertir et définir un message d'erreur pour l'UI + // eslint-disable-next-line no-console + console.warn('Supabase non initialisé — vérifiez REACT_APP_SUPABASE_URL et REACT_APP_SUPABASE_ANON_KEY'); + setError('Supabase non initialisé — vérifiez les variables d\'environnement'); + setLoading(false); + inFlight = false; + return; + } + setLoading(true); + setError(null); + try { + const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ + // DEBUG: récupérer explicitement l'id et toutes les colonnes pour s'assurer que l'identifiant + // `id` est bien présent et peut être mis en correspondance avec `horaire_id` dans perturbations + supabase.from('horaires').select('id, *'), + supabase.from('perturbations').select('*'), + supabase.from('travaux').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; + } + + // debug: afficher ce que Supabase retourne + // eslint-disable-next-line no-console + debug('Supabase horaires fetched', { count: (horairesData || []).length, sample: (horairesData || []).slice(0,5) }); + if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) }); + + // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) + let parsedHoraires = (horairesData || []).map(h => h || {}); + + // Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD; + // essayer de parser pour garantir que stops est un tableau utilisable. + parsedHoraires = parsedHoraires.map(h => { + try { + if (h && typeof h.stops === 'string') { + const parsed = JSON.parse(h.stops); + if (Array.isArray(parsed)) { + return { ...h, stops: parsed }; + } + } + } catch (e) { + // ignore parse error + } + return h; + }); + + // debug: afficher après normalisation + // eslint-disable-next-line no-console + debug('Supabase horaires normalized', { count: parsedHoraires.length, sampleStopsExample: (parsedHoraires[0] && parsedHoraires[0].stops) || null }); + + // stocker la liste complète (non filtrée) pour associer horaire_id -> numero_train if (mounted) setAllHoraires(parsedHoraires || []); + // Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée + // (soit comme gare_depart, soit comme un arrêt intermédiaire dans `stops` / `gares_desservies`) + // Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus) + 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 + const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); + if (destName === normalized) return false; - // Récupérer le type de gare depuis la table `gares` si possible - try { - let fetchedType = 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; - } else if (gareDisplay) { - const needle = `%${gareDisplay}%`; - try { - const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').or(`nom.ilike.${needle},name.ilike.${needle}`).limit(1); - if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; - } catch (e) { - try { - const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', needle).limit(1); - if (gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; - } catch (err) { /* ignore */ } - } - } - if (mounted) setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); - } catch (e) { /* ignore errors reading gares table */ } + // 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; - // Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée en tant qu'ARRIVÉE - 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 de départ (on veut les arrivées) - const departName = normalizeName((h.gare_depart_nom || h.gare_depart || '').toString()); - if (departName && (departName === normalized || departName.includes(normalized) || normalized.includes(departName))) return false; + // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max) + 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 d'arrivée correspond - const arriveeName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); - if (arriveeName && (arriveeName === normalized || arriveeName.includes(normalized) || normalized.includes(arriveeName))) return true; + // 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 si l'arrêt est le terminus d'origine (index==0) - const stopsList = getStops(h); - if (Array.isArray(stopsList) && stopsList.length) { - // compute max index to detect terminus positions - 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 isOrigin = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && Number(s.index) === 0)); - if (isOrigin) continue; - if (stopName === normalized || stopName.includes(normalized) || normalized.includes(stopName)) return true; - } - } - return false; - }); - } else if (gareIdParam) { - const gid = String(gareIdParam); - displayHoraires = displayHoraires.filter(h => { - const departId = (h.gare_depart_id || h.gare_depart || null); - if (departId != null && String(departId) === gid) return false; - const arriveeId = h.gare_arrivee_id || h.gare_arrivee || null; - if (arriveeId != null && String(arriveeId) === 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; - }); + // 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; + }); + } + + // Normaliser date de service (si présente) + 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) })); + + // parse HH:mm or HH:MM:SS into minutes since midnight + 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; + }; + + // enrichir withDates avec heures en minutes pour aujourd'hui et demain + 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 || ''; + // si stops contient la gare ciblée, préférer son 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 = formatTimeNoSeconds(raw); + return { ...h, _rawTime: raw, _arrHM: depHM, _displayTime: displayTime }; + }); + + + // fonctions utilitaires de jours + 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 weekdayIndex = (d) => d.getDay(); // 0 dimanche .. 6 samedi + const weekdayNameForColumn = (d) => { + const i = weekdayIndex(d); + switch(i) { + case 1: return 'circule_lundi'; + case 2: return 'circule_mardi'; + case 3: return 'circule_mercredi'; + case 4: return 'circule_jeudi'; + case 5: return 'circule_vendredi'; + case 6: return 'circule_samedi'; + case 0: return 'circule_dimanche'; + default: return null; + } + }; + + const isPublicHoliday = (d) => { + const y = d.getFullYear(); + const m = d.getMonth() + 1; + const day = d.getDate(); + const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const holidays = [ + `${y}-01-01`, // Jour de l'an + `${y}-05-01`, // Fête du Travail + `${y}-05-08`, // Victoire 1945 + `${y}-07-14`, // Fête nationale + `${y}-08-15`, // Assomption + `${y}-11-01`, // Toussaint + `${y}-11-11`, // Armistice 1918 + `${y}-12-25`, // Noël + ]; + // Dates variables pour 2026 + if (y === 2026) { + holidays.push('2026-04-06'); // Lundi de Pâques + holidays.push('2026-05-14'); // Ascension + holidays.push('2026-05-25'); // Lundi de Pentecôte + } + return holidays.includes(s); + }; + + const parseCirculationFlag = (value, defaultValue) => { + if (value === null || typeof value === 'undefined') { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value === 1; + } + if (typeof value === 'string') { + const s = value.trim().toLowerCase(); + if (['true', '1', 't', 'y', 'yes', 'oui'].includes(s)) { + return true; + } + if (['false', '0', 'f', 'n', 'no', 'non'].includes(s)) { + return false; + } + } + return defaultValue; + }; + + const circulatesOn = (h, d) => { + if (isPublicHoliday(d)) { + // On public holidays, circulation is determined SOLELY by `circule_jours_feries`. + // If the flag is true, it runs. Otherwise, it doesn't. + return parseCirculationFlag(h['circule_jours_feries'], false); + } + + // On a regular day, check the corresponding weekday column. + const col = weekdayNameForColumn(d); + if (!col) return true; // Should not happen, but permissive. + + // For regular weekdays, the default is to run unless specified otherwise. + return parseCirculationFlag(h[col], true); + }; + + // Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_* + // Calculer les travaux applicables et les substitutions + const travauxList = travauxData || []; + + 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._arrHM === 'number') return h._arrHM >= startHM && h._arrHM <= endHM; + return false; + }; + + + + // compute linkage and travaux applicability per horaire + const enriched = enrich.map(h => { + let linked = false; + let appliesToday = false; + let appliesTomorrow = false; + let linkedTravaux = null; + for (const t of travauxList) { + // Extraire identifiants de ligne (différentes variantes possibles) + 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; + + // Si le travail a une ligne attribuée, n'appliquer le travail QUE si la ligne correspond + // (si hLine est absent on considère qu'on ne peut pas appliquer ce travail à cet horaire) + if (typeof tLine !== 'undefined' && tLine !== null) { + if (typeof hLine === 'undefined' || hLine === null) { + // pas de ligne sur l'horaire -> ignorer ce travail pour cet horaire + continue; + } + if (String(tLine) !== String(hLine)) { + // lignes différentes -> ignorer ce travail pour cet horaire + continue; + } + } + + const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero; + let matchRef = ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero)); + if (matchRef) linked = true; + + const appT = travauxApplies(t,h,today); + const appY = travauxApplies(t,h,tomorrow); + if (appT) appliesToday = true; + if (appY) appliesTomorrow = true; + + if (matchRef || appT || appY) { + linkedTravaux = t; + } + } + return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow, _linkedTravauxObj: linkedTravaux }; + }); + + // Build today and tomorrow candidate lists + const todayCandidates = enriched.filter(h => { + if (h._date && h._date !== todayStr) return false; + if (!circulatesOn(h, today)) return false; + if (typeof h._arrHM !== 'number') return false; + if (h._arrHM < todayHM - 1) return false; + + // if travaux apply at this horaire today, hide regular horaires + if (h._travauxAppliesToday && !isSubstitution(h)) return false; + + if (isSubstitution(h)) { + // show substitution only if substitution_disponible AND linked to travaux that apply today + return Boolean(h.substitution_disponible === true && h._linkedToTravaux && h._travauxAppliesToday); + } + + return true; + }); + + const tomorrowCandidates = enriched.filter(h => { + if (h._date && h._date !== tomorrowStr) return false; + if (!circulatesOn(h, tomorrow)) return false; + + // hide regular horaires if travaux apply tomorrow during their time window + if (h._travauxAppliesTomorrow && !isSubstitution(h)) return false; + + if (isSubstitution(h)) { + // show substitution only if substitution_disponible AND linked to travaux that apply tomorrow + return Boolean(h.substitution_disponible === true && h._linkedToTravaux && h._travauxAppliesTomorrow); + } + + return true; + }); + + // final list: if there are any todayCandidates use them; otherwise use tomorrowCandidates + let finalList; + let serviceDateStr; + if (todayCandidates && todayCandidates.length > 0) { + finalList = todayCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); + serviceDateStr = todayStr; + } else { + finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); + serviceDateStr = tomorrowStr; + } + + // Construire un index local des perturbations à partir des données fraîchement récupérées + // Filtrer pour ne garder que les perturbations applicables aujourd'hui (éviter d'appliquer les perturbations d'hier) + + 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); + // si pDate fourni et ne correspond pas à la date de service -> ignorer + if (pDate && pDate !== serviceDateStr) return false; + // si fourche de dates fournie et la date de service n'est pas dans l'intervalle -> ignorer + if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; + // si jours_perturbation fourni -> vérifier que la date de service en fait partie + try { + const joursSet = getPerturbationJours(p); + if (joursSet && !joursSet.has(serviceDateStr)) return false; + } catch (e) { + // ignore parse errors and keep perturbation + } + return true; + } catch (e) { + return true; + } + }); + + const localPerturbMap = buildPerturbMap(filteredPerturb || [], parsedHoraires || []); + // Debug: afficher sommaire de l'index des perturbations + debug('localPerturbMap summary', { + horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length, + numberCount: Object.keys(localPerturbMap.byNumber || {}).length, + stopIdCount: Object.keys(localPerturbMap.byStopId || {}).length, + stopNameCount: Object.keys(localPerturbMap.byStopName || {}).length, + globalCount: (localPerturbMap.global || []).length, + sampleStopNames: Object.keys(localPerturbMap.byStopName || {}).slice(0,10) + }); + + // Annoter chaque horaire avec un statut calculé (synchronisé avec la BDD) + // déterminer la date de service utilisée pour ces finalList (aujourd'hui ou demain) + const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr); + + // 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) {} + + // 10 lignes en mode normal, 20 en mode défilement + setHoraires((annotated || []).slice(0, defilementMode ? 20 : 10)); + setPerturbations(filteredPerturb || []); + setTravaux(travauxData || []); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Erreur Supabase horaires', err); + if (mounted) setError(err.message || String(err)); + } finally { + if (mounted) setLoading(false); + inFlight = false; + } + }; + + // Exécuter immédiatement, puis périodiquement pour synchroniser sans recharger + fetchData(); + const intervalMs = 15000; // 15 secondes par défaut + const intervalId = setInterval(fetchData, intervalMs); + + return () => { mounted = false; clearInterval(intervalId); }; + }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours, defilementMode]); + + // Helpers pour perturbations (memoized plus haut) + + // Création des lignes vides pour créer l'espace + const emptyRows = Array.from({ length: 10 }).map((_, i) => ({ _isEmpty: true, id: `empty-${i}` })); + + // Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie) + const rowsToRender = defilementMode + ? [...horaires, ...emptyRows, ...horaires, ...emptyRows] + : horaires; + + // Calcul dynamique de l'animation CSS + useEffect(() => { + if (defilementMode && horaires.length > 0) { + // Nombre de lignes de données + const dataRows = horaires.length; + // 10 lignes vides + const emptyRowsCount = 10; + + // Temps pour défiler les données (2s par ligne au lieu de 1s) + const dataScrollTime = dataRows * 2; + // Temps pour défiler l'espace vide (2s par ligne) + const emptyScrollTime = emptyRowsCount * 2; + // Temps de pause de 10 secondes + const pauseTime = 10; + + // Durée totale d'un cycle (défilement + pause) + const totalAnimationTime = dataScrollTime + emptyScrollTime + pauseTime; + + // Calcul des pourcentages pour l'animation + // On commence à 0 + // À la fin du défilement des données, on a défilé de (dataRows / (totalCycleRows * 2)) * 100% de la hauteur totale + // À la fin du défilement de l'espace, on a défilé de (totalCycleRows / (totalCycleRows * 2)) * 100% (soit 50%) + // Ensuite on fait une pause pendant 10 secondes (donc le % de temps avance, mais pas la translation) + + const scrollEndPercent = ((dataScrollTime + emptyScrollTime) / totalAnimationTime) * 100; + + const styleSheet = document.createElement("style"); + styleSheet.innerText = ` + @keyframes customScrollMarquee { + 0% { transform: translateY(0); } + ${scrollEndPercent}% { transform: translateY(-50%); } + 100% { transform: translateY(-50%); } } + .aff-classiques-arrivees__scroll-track { + animation: customScrollMarquee ${totalAnimationTime}s linear infinite !important; + } + `; + document.head.appendChild(styleSheet); + + return () => { + document.head.removeChild(styleSheet); + }; + } + }, [defilementMode, horaires]); - 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 renderRow = (h, idx) => { + if (h._isEmpty) { + return
  • ; + } - const now = new Date(); - const todayHM = now.getHours()*60 + now.getMinutes(); - const enrich = withDates.map(h => { - let raw = h.heure_arrivee || h.arrival_time || 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 arrHM = parseTimeHM(raw); - const displayTime = formatTimeNoSeconds(raw); - return { ...h, _rawArrTime: raw, _arrHM: arrHM, _displayTime: displayTime }; - }); + const horaire = h || {}; + const op = horaire.type_train || horaire.op || ''; + const isCarTER = (op === 'Car TER' || op === 'Autocar'); + const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure || ''); + const origin = horaire.gare_depart_nom || ''; + const platform = (horaire._displayPlatform || '').toString().trim(); + const stationId = horaire.gare_arrivee_id || horaire.gare_id || horaire.destination_id || (gareIdParam || null); + const stationName = gareDisplay || horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination_name || null; + const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + const isSuppressed = status && status.label === 'supprimé'; + const isDelayed = status && status.label && String(status.label).startsWith('retardé'); + + let showPlatform = !isSuppressed && Boolean(platform); + if (showPlatform) { + const nowHM = currentTime.getHours()*60 + currentTime.getMinutes(); + const sched = (typeof horaire._arrHM === 'number') ? horaire._arrHM : parseTimeHM(horaire.heure_arrivee || horaire.time || horaire.heure || horaire._rawTime || ''); + const threshold = thresholdForStationType(stationType); + if (typeof sched === 'number') { + let diff = sched - nowHM; + // Gérer le passage à minuit + if (diff < -720) { // e.g., now 23:00, sched 01:00, diff is ~ -22 hours + diff += 1440; // treat as next day + } + showPlatform = (diff >= 0 && diff <= threshold); + } else { + showPlatform = false; + } + } - // travaux processing (copié et adapté) - 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 isSub = isSubstitution(horaire); + const infoText = isSub + ? (horaire.motif_substitution && horaire.motif_substitution !== 'TRAVAUX' && horaire.motif_substitution !== 'Travaux.' ? horaire.motif_substitution : 'Autocar cour de la gare Plateforme 14') + : (status && status.causeDetails ? status.causeDetails : ''); + const trainNumber = horaire.numero_train || horaire.numero || horaire.number || ''; - const weekdayIndex = (d) => d.getDay(); - const weekdayNameForColumn = (d) => { - const i = weekdayIndex(d); - switch(i) { case 1: return 'circule_lundi'; case 2: return 'circule_mardi'; case 3: return 'circule_mercredi'; case 4: return 'circule_jeudi'; case 5: return 'circule_vendredi'; case 6: return 'circule_samedi'; case 0: return 'circule_dimanche'; default: return null; } - }; - const isPublicHoliday = (d) => { - const y = d.getFullYear(); - const m = d.getMonth() + 1; - const day = d.getDate(); - const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - const holidays = [ - `${y}-01-01`, // Jour de l'an - `${y}-05-01`, // Fête du Travail - `${y}-05-08`, // Victoire 1945 - `${y}-07-14`, // Fête nationale - `${y}-08-15`, // Assomption - `${y}-11-01`, // Toussaint - `${y}-11-11`, // Armistice 1918 - `${y}-12-25`, // Noël - ]; - // Dates variables pour 2026 - if (y === 2026) { - holidays.push('2026-04-06'); // Lundi de Pâques - holidays.push('2026-05-14'); // Ascension - holidays.push('2026-05-25'); // Lundi de Pentecôte - } - return holidays.includes(s); - }; + return ( +
  • - const circulatesOn = (h, d) => { - if (isPublicHoliday(d)) { - const v = h['circule_jours_feries']; - if (typeof v === 'boolean') return v; - if (typeof v === 'number') return v === 1; - if (typeof v === 'string') { - const s = v.trim().toLowerCase(); - return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; - } - return (typeof v === 'undefined' || v === null) ? false : Boolean(v); - } - const col = weekdayNameForColumn(d); - if (!col) return true; - const v = h[col]; - if (typeof v === 'boolean') return v; - if (typeof v === 'number') return v === 1; - if (typeof v === 'string') { - const s = v.trim().toLowerCase(); - return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; - } - return (typeof v === 'undefined' || v === null) ? true : Boolean(v); - }; + {/* COL 1 : Logo opérateur */} +
    + {isCarTER ? ( +
    + SNCF Voyageurs +
    + ) : ( + + )} +
    - const travauxList = travauxData || []; - 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._arrHM === 'number') return h._arrHM >= startHM && h._arrHM <= endHM; - return false; - }; + {/* COL 2 : Type + Numéro (4s) OU Statut (4s) */} +
    + {showStatus ? ( + isSuppressed ? ( +
    supprimé
    + ) : isDelayed ? ( +
    +
    retard
    +
    {status.delayMinutes} min.
    +
    + ) : ( +
    à l'heure
    + ) + ) : ( + <> +
    {op}
    +
    {trainNumber}
    + + )} +
    - const isSubstitution = (h) => { - if (!h) return false; - if (h.substitution_disponible === true) return true; - const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true; - const subStr = (typeof h.substitution === 'string' && ['true','1','yes','y','t'].includes(h.substitution.toLowerCase())); - const typeMatch = h.type_train && /subst|substitut|remplac/i.test(String(h.type_train)); - const commentMatch = h.comment && /subst|substitut|remplac/i.test(String(h.comment)); - return Boolean(subFlag || subStr || typeMatch || commentMatch); - }; + {/* COL 3 : Heure (grande, jaune) */} +
    +
    {time}
    +
    - 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; - 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 }; - }); + {/* COL 4 : Destination + Info cause (slide-in 5s/5s) */} +
    + + {showInfoCause && infoText ? ( +
    +
    +
    |
    +
    {infoText}
    +
    +
    + ) : null} +
    - const todayCandidates = enriched.filter(h => { - if (h._date && h._date !== todayStr) return false; - if (!circulatesOn(h, today)) return false; - if (typeof h._arrHM !== 'number') return false; - if (h._arrHM <= todayHM) return false; - if (h._travauxAppliesToday && !isSubstitution(h)) return false; - if (isSubstitution(h)) return Boolean(h.substitution_disponible === true && h._linkedToTravaux && h._travauxAppliesToday); - return true; - }); + {/* COL 5 : Quai OU icône bus */} +
    + {isSub || isCarTER ? ( + Car + ) : showPlatform ? ( +
    +
    {platform}
    +
    + ) : null} +
    - const tomorrowCandidates = enriched.filter(h => { - if (h._date && h._date !== tomorrowStr) return false; - if (!circulatesOn(h, tomorrow)) return false; - if (h._travauxAppliesTomorrow && !isSubstitution(h)) return false; - if (isSubstitution(h)) return Boolean(h.substitution_disponible === true && h._linkedToTravaux && h._travauxAppliesTomorrow); - return true; - }); - - let finalList; let serviceDateStr; - if (todayCandidates && todayCandidates.length > 0) { finalList = todayCandidates.slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); serviceDateStr = todayStr; } - else { finalList = (tomorrowCandidates || []).slice().sort((a,b)=> (a._arrHM||99999)-(b._arrHM||99999)); serviceDateStr = tomorrowStr; } - - const localPerturbMap = buildPerturbMap(perturbData || [], parsedHoraires || []); - debug('localPerturbMap summary', { - horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length, - numberCount: Object.keys(localPerturbMap.byNumber || {}).length, - }); - - const annotated = applyPerturbationsToHoraires(finalList, perturbData || [], null, null, serviceDateStr); - 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) {} - - setHoraires((annotated || []).slice(0, 9)); - setPerturbations(perturbData || []); - setTravaux(travauxData || []); - } catch (err) { - console.error('Erreur Supabase horaires (arrivees)', err); - if (mounted) setError(err.message || String(err)); - } finally { - if (mounted) setLoading(false); - inFlight = false; - } - }; - fetchData(); - const intervalMs = 15000; - const intervalId = setInterval(fetchData, intervalMs); - return () => { mounted = false; clearInterval(intervalId); }; - }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, applyPerturbationsToHoraires, debug, getStops, parseTimeHM]); +
  • + ); + }; return ( -
    -
    arrivées
    -
      - {horaires.length === 0 ? ( -
    • -
      Aucun horaire disponible.
      -
      lignes brutes renvoyées : {rawDebug.count}
      - {rawDebug.count > 0 && rawDebug.sample && rawDebug.sample.length > 0 ? ( -
      {JSON.stringify(rawDebug.sample.slice(0,2), null, 2).slice(0,1000)}{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).length > 1000 ? '…(truncated)' : ''}
      - ) : null} -
    • - ) : horaires.map((h, idx) => { - const horaire = h || {}; - const op = horaire.type_train || horaire.op || ''; - const number = horaire.numero_train || horaire.number || horaire.numero || ''; - let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire.heure || ''); +
      - // afficher la gare de départ à la place de la destination - const departure = horaire.gare_depart_nom || horaire.gare_depart || ''; - const platform = (horaire._displayPlatform || horaire.arrival_platform || horaire.platform || '').toString().trim(); + {/* Watermark "départs" — diagonal droite */} +
      arrivées
      - const stationId = horaire.gare_arrivee_id || horaire.gare_arrivee || (gareIdParam || null); - const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_arrivee_nom || horaire.gare_arrivee || null); + {defilementMode ? ( + /* ── MODE DÉFILEMENT : marquee vertical infini ── */ +
      + {horaires.length === 0 ? ( +
        +
      • +
        Aucun horaire disponible.
        +
      • +
      + ) : ( +
        + {rowsToRender.map((h, idx) => renderRow(h, idx))} +
      + )} +
      + ) : ( + /* ── MODE NORMAL : 10 lignes statiques ── */ +
        + {horaires.length === 0 ? ( +
      • +
        Aucun horaire disponible.
        +
        lignes brutes renvoyées : {rawDebug.count}
        + {rawDebug.count > 0 && rawDebug.sample && rawDebug.sample.length > 0 ? ( +
        +				  {JSON.stringify(rawDebug.sample.slice(0,2), null, 2).slice(0,1000)}
        +				  {JSON.stringify(rawDebug.sample.slice(0,2), null, 2).length > 1000 ? '…(truncated)' : ''}
        +				
        + ) : null} +
      • + ) : horaires.map((h, idx) => renderRow(h, idx))} +
      + )} - const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); + {/* Horloge — superposée en bas à droite */} +
      + +
      - if (status && status.label === 'supprimé' && showStatus) { - time = ''; - } else if (!horaire._displayTime && status && typeof status.delayMinutes === 'number' && !isNaN(status.delayMinutes)) { - if (typeof horaire._arrHM === 'number') { - time = formatTimeFromHM(horaire._arrHM + status.delayMinutes); - } else { - const tryParse = (s) => { - if (!s) return null; - 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 base = tryParse(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire._rawArrTime || ''); - if (typeof base === 'number') time = formatTimeFromHM(base + status.delayMinutes); - } - } - - const statusClass = status && status.label === 'supprimé' ? 'status-suppressed-arrivees' : (status && status.label && String(status.label).startsWith('retardé') ? 'status-delayed-arrivees' : ''); - // Ne pas afficher le quai si le train est supprimé ou si aucun quai n'est défini - let showPlatform = !(status && status.label === 'supprimé') && Boolean(platform); - if (showPlatform) { - const nowHM = currentTime.getHours()*60 + currentTime.getMinutes(); // Use currentTime - const sched = (typeof horaire._arrHM === 'number') ? horaire._arrHM : parseTimeHM(horaire.heure_arrivee || horaire.arrival_time || horaire.time || horaire._rawArrTime || ''); - const threshold = thresholdForStationType(stationType); - if (typeof sched === 'number' && typeof threshold === 'number' && threshold > 0) { - // n'afficher le quai que si l'heure courante est au moins (scheduled - threshold) - showPlatform = (nowHM >= (sched - threshold)); - } - } - - return ( -
    • -
      -
      - {showStatus ? ( -
      -
      {status ? status.label : ''}
      -
      {status ? status.detail : ''}
      -
      - ) : ( -
      -
      {op}
      -
      {number}
      -
      - )} -
      -
      {time}
      -
      {departure}
      - {showPlatform ? ( -
      {platform}
      - ) : null} -
    • - ); - })} -
    -
    -
    -
    -
    - ); - } +
    + ) +} \ No newline at end of file diff --git a/src/pages/afficheurs/classiques/ClassiquesDeparts.css b/src/pages/afficheurs/classiques/ClassiquesDeparts.css index f552020..b148263 100644 --- a/src/pages/afficheurs/classiques/ClassiquesDeparts.css +++ b/src/pages/afficheurs/classiques/ClassiquesDeparts.css @@ -1,15 +1,6 @@ - -.aff-classiques-departs__container{ - --blue-dark: #173771; /* bande foncée */ - --blue: #275e9f; /* bande claire */ - --accent-yellow: #e1c14c; /* heure */ - --white: #f3f3f3; - --rows: 9; /* 9 horaires visibles + 1 footer */ - --footer-height: 100px; /* hauteur de la dernière 'ligne' servant de footer */ - --watermark-blue: rgba(10,120,180,0.15); -} - -/* Déclaration des fontes Achemine (dans public/fonts/) */ +/* ══════════════════════════════════════════════════════════════════ + Fontes Achemine + ══════════════════════════════════════════════════════════════════ */ @font-face { font-family: 'Achemine'; src: url('/public/fonts/achemine_regular.ttf') format('truetype'); @@ -32,367 +23,400 @@ font-display: swap; } -/* Conteneur plein écran pour l'afficheur */ -.aff-classiques-departs__container{ +/* ══════════════════════════════════════════════════════════════════ + ANIMATIONS + ══════════════════════════════════════════════════════════════════ */ + +/* Info cause perturbation — slide-in de droite vers la gauche */ +@keyframes infoWrapperGrow { + from { max-width: 0; } + to { max-width: 30vw; } +} + +@keyframes infoSlideIn { + from { transform: translateX(100%); opacity: 0; } + 40% { opacity: 1; } + to { transform: translateX(0%); opacity: 1; } +} + +/* ══════════════════════════════════════════════════════════════════ + CONTENEUR PRINCIPAL + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__container { + /* Couleurs inspirées du vrai afficheur SNCF */ + --yellow: #FFCC00; + --blue-bg: #003a79; + --blue-odd: #0163ab; + --blue-even: #003a79; + --text-white: #ffffff; + --text-muted: #7EB8E0; + position: relative; - width: 100%; + width: 100vw; height: 100vh; - /* autoriser l'affichage des boîtes agrandies qui débordent des lignes */ - overflow: visible; - font-family: "Achemine", "Helvetica Neue", Arial, sans-serif; - background: var(--blue-dark); - /* plus de padding-bottom : le footer sera superposé en position absolute */ -} - -/* Boîte affichant le nom de la gare configuré via ?gare=Nom-de-la-gare */ -.aff-classiques-departs__station-box{ - position: absolute; - left: 18px; - top: 12px; - color: #ffffff; - background: rgba(0,0,0,0.08); - padding: 6px 10px; - border-radius: 6px; - font-weight: 700; - z-index: 40; -} - -/* arrondir légèrement le bas de l'afficheur pour coller à la maquette */ -.aff-classiques-departs__container{ - border-bottom-right-radius: 10px; - border-bottom-left-radius: 4px; -} - -/* Liste des lignes (bandes horizontales) */ -.aff-classiques-departs__list{ - list-style: none; - margin: 0; - padding: 0; - /* La liste occupe toute la hauteur de l'écran; le footer sera affiché - en position absolue par-dessus la partie inférieure. */ - height: 100vh; - display: flex; - flex-direction: column; -} - -.aff-classiques-departs__row{ - display: flex; - /* laisser les colonnes occuper toute la hauteur de la ligne, puis centrer leur contenu */ - align-items: stretch; - box-sizing: border-box; - padding: 0 1.2vw; - /* Répartit l'espace en var(--rows) parts ; on utilise 100% / var(--rows) pour que - 9 lignes + 1 footer occupent l'écran de façon uniforme. */ - flex: 0 0 calc(100% / var(--rows)); - min-height: 50px; /* garde une bonne hauteur sur petits écrans */ - max-height: 120px; - position: relative; /* pour positionner la .platform-box en absolu */ - overflow: visible; /* laisser la platform-box déborder si agrandie */ -} - -.aff-classiques-departs__row:nth-child(odd){ - background: var(--blue); -} -.aff-classiques-departs__row:nth-child(even){ - background: var(--blue-dark); -} - -/* Colonne logo */ -.aff-classiques-departs__col-logo{ - max-width: 100px; - display:flex; - /* organiser verticalement les éléments et les centrer verticalement */ - flex-direction: column; - justify-content: center; /* centre verticalement le contenu (logo + meta) */ - align-items: flex-start; /* garder l'alignement horizontal à gauche */ - flex: 0 0 10%; /* largeur fixe pour se comporter comme une colonne de tableau */ -} -.aff-classiques-departs__logo-box{ - /* pastille/logo : ne pas forcer la hauteur pour permettre l'empilement vertical */ - height: auto; - width: 100px; - display:flex; - align-items:flex-start; - justify-content:center; - font-weight:800; - font-size: clamp(14px, 1.6vw, 20px); - box-sizing: border-box; - flex: 0 0 auto; - align-self: flex-start; /* centrer la pastille dans la colonne */ -} - -/* Logo SVG chargé depuis public/type */ -.aff-classiques-departs__op-logo{ - max-width: 90%; - max-height: 100%; - height: auto; -} - -/* Texte à l'intérieur de la pastille/logo (quai) */ -.aff-classiques-departs__logo-text{ - text-align: center; - /* taille plus grande, responsive ; s'assure qu'elle reste lisible sur petits et grands écrans */ - font-size: 20px; - line-height: 1; - letter-spacing: 0.5px; -} - - -/* Colonne heure */ -.aff-classiques-departs__col-time{ - max-width: 15%; - padding-left: 20px; - display:flex; - /* colonne verticale : centrer verticalement l'heure */ - flex-direction: column; - align-items: center; - justify-content: center; - flex: 0 0 18%; /* largeur fixe pour alignement table-like */ -} -.aff-classiques-departs__time{ - color: var(--accent-yellow); - font-weight:850; - /* tailles responsives similaires à l'image */ - font-size: clamp(26px, 7.5vw, 70px); - line-height: 1; - display: flex; - align-items: baseline; /* centre le chiffre verticalement */ - height: auto; - width: 100%; /* occupe toute la colonne pour être centré */ - text-align: center; -} - -/* Destination */ -.aff-classiques-departs__col-destination{ - flex: 1 1 auto; /* colonne flexible qui prend l'espace restant */ - display: flex; - /* colonne verticale : centrer verticalement la destination */ - flex-direction: column; - justify-content: center; - align-items: flex-start; /* garder le texte aligné à gauche */ -} -.aff-classiques-departs__destination{ - color: var(--white); - font-weight: 500; - font-size: clamp(28px, 8vw, 80px); - white-space: nowrap; - text-overflow: ellipsis; overflow: hidden; + background: var(--blue-bg); + font-family: "Achemine", "Arial Narrow", Arial, sans-serif; } -/* Colonne Platform (quai) affichée à droite de chaque ligne */ -.aff-classiques-departs__col-platform{ - /* La colonne laisse la boîte gérer sa taille ; on aligne la boîte à droite - et l'on centre verticalement pour qu'elle suive la hauteur de la ligne. */ - display:flex; - flex: 0 0 8%; - align-items: center; - justify-content: flex-end; - padding-right: 0.5vw; - box-sizing: border-box; -} -.aff-classiques-departs__platform-box{ - /* Boîte quai : garder un format carré mais ne pas forcer l'étirement vertical */ - height: 90px; /* limite maximale, garde l'aspect visuel */ - width: 90px; - aspect-ratio: 1 / 1; /* carré strict */ - display:flex; - align-items:center; - justify-content:center; - background: transparent; /* intérieur transparent pour montrer la couleur de la ligne */ - border-radius: 12px; - box-sizing: border-box; - border: 4px solid #ffffff; /* bord blanc épais */ - padding: 8px; /* espace intérieur pour éloigner le texte du bord */ - flex: 0 0 auto; - align-self: center; -} -.aff-classiques-departs__platform-text{ - color: #ffffff; - font-weight: 900; - /* réduire légèrement la taille du texte afin qu'il ne touche pas la bordure */ - font-size: clamp(60px, 2.6vw, 100px); - line-height:1; - letter-spacing: 0.8px; -} - -/* Colonne Type de train (victoire de la séparation avec la colonne logo) */ -.aff-classiques-departs__col-train-type{ - max-width: 9%; - display:flex; - /* colonne verticale : centrer verticalement l'heure */ - flex-direction: column; - align-items: stretch; - justify-content: center; - flex: 0 0 18%; /* largeur fixe pour alignement table-like */ -} -.aff-classiques-departs__train-type-box{ - height: auto; - aspect-ratio: 1 / 1; - display:flex; - align-items:center; - justify-content:center; - background: transparent; - border-radius: 10px; - box-sizing: border-box; -} -.aff-classiques-departs__train-type-fallback{ text-align:center; } - - -/* Numéro de train / opérateur (texte sous logo) */ -.aff-classiques-departs__train-meta{ - flex-direction:column; - align-items:flex-start; - /* centrer verticalement sans forcer la hauteur (pour permettre l'empilement) */ - justify-content: center; - color: #ffffff; - height: auto; - flex: 0 0 auto; -} -.aff-classiques-departs__train-meta .aff-classiques-departs__op-name{ - font-weight:900; - font-size:1.50rem; /* plus visible */ - padding-left: 20px; - line-height:1; - justify-content: flex-start; - text-align: left; /* aligner à gauche dans la colonne */ -} -.aff-classiques-departs__train-meta .aff-classiques-departs__op-number{ - font-weight:500; - font-size:1.25rem; - padding-left: 20px; - text-align: left; /* aligner à gauche */ - margin: 0 0 2px 0; -} - -/* Statuts spéciaux : retardé / supprimé doivent ressortir en jaune */ -.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--delayed .aff-classiques-departs__op-name, -.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--delayed .aff-classiques-departs__op-number, -.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--suppressed .aff-classiques-departs__op-name, -.aff-classiques-departs__train-meta.aff-classiques-departs__train-meta--suppressed .aff-classiques-departs__op-number{ - color: var(--accent-yellow); -} - -/* Si on veut un alignement plus spécifique quand train-meta apparaît dans la colonne de type */ -.aff-classiques-departs__col-train-type .aff-classiques-departs__train-meta{ align-items: flex-start; justify-content: center; } -.aff-classiques-departs__col-train-type .aff-classiques-departs__train-meta .aff-classiques-departs__op-name, -.aff-classiques-departs__col-train-type .aff-classiques-departs__train-meta .aff-classiques-departs__op-number{ text-align: left; } - -/* Watermark vertical à droite */ -.aff-classiques-departs__watermark{ +/* ══════════════════════════════════════════════════════════════════ + WATERMARK "départs" — pivotée à 90°, fixée à droite + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__watermark { position: absolute; - right: 8px; + right: -9vh; top: 50%; transform: translateY(-50%) rotate(-90deg); transform-origin: center; - font-size: 10.8vw; - color: var(--watermark-blue); - font-weight:800; - letter-spacing: 6px; + /* Taille géante, très visible comme dans l'image */ + font-size: 23vh; + color: #1389D2; + font-weight: 900; + letter-spacing: 0.05em; pointer-events: none; user-select: none; + z-index: 1; + white-space: nowrap; + opacity: 0.3; /* Opacité diminuée */ } -/* Petite pastille H en haut à droite (optionnelle) */ -.aff-classiques-departs__helpline{ - position: absolute; - right: 18px; - top: 12px; - width:80px; - height:80px; - border-radius:6px; - border:3px solid #fff; - color:#fff; - display:flex; - align-items:center; - justify-content:center; - font-weight:900; +/* ══════════════════════════════════════════════════════════════════ + LISTE STATIQUE — 10 lignes sur exactement 100vh + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__list { + list-style: none; + margin: 0; + padding: 0; + height: 100vh; + display: flex; + flex-direction: column; + position: relative; + z-index: 2; } -/* Horloge en bas à droite, proche du bord droit (à 400px du bord droit) */ -.aff-classiques-departs__clock-container{ - position: relative; /* contenu positionné par le footer */ - pointer-events: none; - z-index: 30; /* au-dessus du footer */ - display: inline-flex; - align-items: center; - height: 100%; /* prendra la hauteur de la ligne footer */ +/* ══════════════════════════════════════════════════════════════════ + MODE DÉFILEMENT — conteneur + track animé + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__scroll-container { + height: 100vh; + overflow: hidden; + position: relative; + z-index: 2; } -.aff-classiques-departs__clock-box{ - display: inline-flex; + +/* Le track contient 2× les données pour une boucle seamless */ +.aff-classiques-departs__scroll-track { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +/* ══════════════════════════════════════════════════════════════════ + ROW — chaque ligne occupe 1/10 de 100vh + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__row { + display: flex; align-items: center; - justify-content: center; - background: var(--blue-dark); /* fond foncé interne comme sur la maquette */ - border-radius: 10px; - padding: 4px 8px; /* réduire padding pour diminuer légèrement la taille globale */ - border: 6px solid #ffffff; /* bordure un peu moins épaisse */ - box-shadow: 0 2px 0 rgba(0,0,0,0.12); + flex: 0 0 calc(100vh / 10); + height: calc(100vh / 10); + min-height: 0; + padding: 0; box-sizing: border-box; overflow: hidden; - /* hauteur adaptée pour rentrer exactement dans la ligne footer */ - height: calc(var(--footer-height) - 12px); - min-height: 34px; } -/* Footer visuel : barre foncée en bas qui chevauche la liste */ -.aff-classiques-departs__footer{ - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: var(--footer-height); +/* Alternance de tons bleus (comme l'image) */ +.aff-classiques-departs__row:nth-child(odd) { background: var(--blue-odd); } +.aff-classiques-departs__row:nth-child(even) { background: var(--blue-even); } + +.aff-classiques-departs__row--empty { + flex-direction: column; + justify-content: center; + align-items: center; + color: var(--text-white); + font-size: 3vh; + text-align: center; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 1 — Logo opérateur (~9 % largeur) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__col-logo { + width: 9vw; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; - pointer-events: none; - z-index: 20; /* en dessous de l'horloge */ -} -.aff-classiques-departs__footer .aff-classiques-departs__footer-inner{ - width: 100%; height: 100%; - position: relative; + padding: 0 0.4vw; + box-sizing: border-box; +} + +.aff-classiques-departs__logo-box { + width: 100%; + /* Logo prend ~65% de la hauteur de la ligne */ + height: 6vh; display: flex; - align-items: center; /* centre verticalement le contenu (la clock) */ - justify-content: flex-end; /* place la clock à droite */ - padding-right: 12px; /* espace entre le bord et la clock pour le coin arrondi */ - /* léger dégradé pour donner l'effet de bande plus claire en haut */ - background: linear-gradient(to bottom, rgba(11,123,180,0.18) 0%, var(--blue-dark) 30%); - box-shadow: inset 0 6px 0 rgba(0,0,0,0.08); - /* arrondir côté droit comme sur la maquette */ - + align-items: center; + justify-content: center; } -/* légère ligne (accent) en haut du footer pour séparer visuellement */ -.aff-classiques-departs__footer .aff-classiques-departs__footer-inner::before{ - content: ''; +.aff-classiques-departs__op-logo { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.aff-classiques-departs__logo-text { + color: var(--text-white); + font-weight: 700; + font-size: 2vh; + text-align: center; + word-break: break-word; + line-height: 1.1; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 2 — Type + Numéro OU Statut (~11 % largeur) + Alterne toutes les 4 secondes + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__col-meta { + width: 11vw; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 0.5vw 0 0; + box-sizing: border-box; + gap: 0.05vh; + overflow: hidden; + text-align: center; +} + +/* Vue normale — type (ex: "MOBIGO") */ +.aff-classiques-departs__train-type { + font-size: 2.2vh; + color: var(--text-white); + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +/* Vue normale — numéro de sillon (ex: "894251") */ +.aff-classiques-departs__train-number { + font-size: 2.2vh; + color: var(--text-white); + font-weight: 400; + line-height: 1.3; + white-space: nowrap; +} + +/* Vue statut — à l'heure */ +.aff-classiques-departs__status-ontime { + font-size: 2.2vh; + color: var(--text-white); + font-weight: 400; + line-height: 1.3; + white-space: nowrap; +} + +/* Vue statut — retardé */ +.aff-classiques-departs__status-delayed { + display: flex; + flex-direction: column; + gap: 0; + line-height: 1.15; +} + +.aff-classiques-departs__status-label { + font-size: 2.2vh; + color: var(--yellow); + font-weight: 500; + white-space: nowrap; +} + +.aff-classiques-departs__status-value { + font-size: 2.2vh; + color: var(--yellow); + font-weight: 700; + white-space: nowrap; +} + +/* Vue statut — supprimé */ +.aff-classiques-departs__status-suppressed { + font-size: 2.2vh; + color: var(--yellow); + font-weight: 700; + white-space: nowrap; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 3 — Heure (~15 % largeur) + Grande, jaune vif (#FFCC00), bold — comme dans l'image + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__col-time { + width: 15vw; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-start; + height: 100%; + padding: 0 0.3vw 0 0; + box-sizing: border-box; +} + +.aff-classiques-departs__time { + /* Jaune vif exactement comme dans la vidéo/image */ + color: var(--yellow); + font-weight: 700; + /* Grande taille — occupe ~65% de la hauteur de ligne */ + font-size: 6.5vh; + line-height: 1; + white-space: nowrap; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 4 — Destination + Info cause (flex:1 — prend tout le reste) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__col-destination { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; + overflow: hidden; + height: 100%; + padding-right: 0.5vw; +} + +/* Texte destination — blanc, bold, très grand */ +.aff-classiques-departs__destination { + color: var(--text-white); + font-weight: 700; + font-size: 6.5vh; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; + min-width: 0; +} + +/* ─── Wrapper animé cause perturbation ────────────────────────── */ +.aff-classiques-departs__info-wrapper { + flex-shrink: 0; + overflow: hidden; + max-width: 0; + animation: infoWrapperGrow 0.85s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.aff-classiques-departs__info-inner { + display: flex; + align-items: center; + transform: translateX(100%); + animation: infoSlideIn 0.85s cubic-bezier(0.4, 0, 0.2, 1) forwards; + white-space: nowrap; +} + +.aff-classiques-departs__separator { + color: var(--text-white); + font-size: 4.5vh; + font-weight: 200; + line-height: 1; + flex-shrink: 0; + padding: 0 0.9vw; +} + +.aff-classiques-departs__info-text { + color: var(--text-white); + font-size: 1.9vh; + font-weight: 400; + white-space: normal; + overflow: hidden; + line-height: 1.25; + max-width: 22vw; +} + +/* ══════════════════════════════════════════════════════════════════ + COL 5 — Quai OU icône bus (~6.5 % largeur) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__col-platform { + width: 6.5vw; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + box-sizing: border-box; + padding: 0.5vh 0.4vw; +} + +/* Boîte quai — bordure blanche */ +.aff-classiques-departs__platform-box { + width: 6vh; + height: 6vh; + border: 2.5px solid rgba(255, 255, 255, 0.88); + display: flex; + align-items: center; + justify-content: center; + border-radius: 7px; + background: transparent; + box-sizing: border-box; +} + +.aff-classiques-departs__platform-text { + color: var(--text-white); + font-weight: 700; + font-size: 4.5vh; + line-height: 1; +} + +/* ══════════════════════════════════════════════════════════════════ + HORLOGE — superposée en bas à droite (z-index élevé) + ══════════════════════════════════════════════════════════════════ */ +.aff-classiques-departs__footer { position: absolute; - left: 0; - right: 0; - top: 0; - height: 8px; - background: rgba(255,255,255,0.03); + right: 1.2vw; + bottom: 0.8vh; + z-index: 30; pointer-events: none; - border-top-right-radius: 12px; } -.aff-classiques-departs__clock-main{ - color: #ffffff; - font-weight: 800; - /* légèrement plus petit pour tenir dans le footer */ - font-size: clamp(28px, 4.2vw, 60px); - line-height: 1; - font-family: "Achemine", "Helvetica Neue", Arial, sans-serif; - margin-right: 6px; + +.aff-classiques-departs__clock-container { + display: flex; + align-items: center; } -.aff-classiques-departs__clock-seconds{ - color: var(--accent-yellow); + +.aff-classiques-departs__clock-box { + border: 2.5px solid rgba(255, 255, 255, 0.88); + background: rgba(0, 51, 102, 0.93); + border-radius: 8px; + padding: 0.4vh 1.1vw; + display: flex; + align-items: baseline; + gap: 0.5vw; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.65); +} + +.aff-classiques-departs__clock-main { + color: var(--text-white); font-weight: 800; - font-size: clamp(16px, 2.8vw, 36px); + font-size: 4.4vh; line-height: 1; } -/* Ajustements pour petits écrans */ -@media (max-width: 700px){ - .aff-classiques-departs__col-logo{width: 18%;} - .aff-classiques-departs__col-time{width: 24%;} - +.aff-classiques-departs__clock-seconds { + color: var(--yellow); + font-weight: 800; + font-size: 2.7vh; + line-height: 1; } + +/* masqué */ +.aff-classiques-departs__station-box { display: none; } \ No newline at end of file diff --git a/src/pages/afficheurs/classiques/ClassiquesDeparts.js b/src/pages/afficheurs/classiques/ClassiquesDeparts.js index 757ed55..d883100 100644 --- a/src/pages/afficheurs/classiques/ClassiquesDeparts.js +++ b/src/pages/afficheurs/classiques/ClassiquesDeparts.js @@ -1,7 +1,8 @@ - import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesDeparts.css'; import supabase from '../../../lib/supabase'; +import Logo from '../../../components/Logo'; +import MarqueeText from '../../../components/MarqueeText'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -22,46 +23,15 @@ const formatTimeNoSeconds = (v) => { return String(v).slice(0,5).replace(':','h'); }; -// Données proviennent exclusivement de la table `horaires`. -function Logo({op}){ - // Tente de charger un logo SVG depuis /type/ en fonction de l'opérateur. - // mapping pour opérateurs connus -> noms de fichiers dans public/type - const mapping = { - 'TGV inOUI': 'logo-inoui.svg', - 'TGV inOUI ': 'logo-inoui.svg', - 'TGV': 'logo-tgv.svg', - 'TER': 'sncf-voyageurs-logo.png', - 'MOBIGO': 'logo-mobigo.svg', - 'TGV Lyria': 'logo-lyria.svg', - 'TGV Lyria ': 'logo-lyria.svg', - }; - - const defaultFileFromOp = (name) => { - if (!name) return null; - // nettoyer et fabriquer un nom plausible : "logo-.svg" - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); - return `logo-${slug}.svg`; - }; - - const fileName = mapping[op] || mapping[op.split(' ')[0]] || defaultFileFromOp(op); - - const [hasImg, setHasImg] = React.useState(true); - - return ( -
    - {hasImg && fileName ? ( - {op} setHasImg(false)} - /> - ) : ( -
    {op.split(' ')[0]}
    - )} -
    - ); -} +const isSubstitution = (h) => { + if (!h) return false; + if (h.substitution_disponible === true) return true; + const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true; + const subStr = (typeof h.substitution === 'string' && ['true','1','yes','y','t'].includes(h.substitution.toLowerCase())); + const typeMatch = h.type_train && /subst|substitut|remplac/i.test(String(h.type_train)); + const commentMatch = h.comment && /subst|substitut|remplac/i.test(String(h.comment)); + return Boolean(subFlag || subStr || typeMatch || commentMatch); +}; // (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) @@ -95,13 +65,17 @@ export default function ClassiquesDeparts(){ const gareIdParam = params.get('gare_id') || params.get('gareId'); // gérer les +, décoder et remplacer les tirets par des espaces const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + // Mode défilement (marquee vertical infini) si ?defilement=true dans l'URL + const defilementMode = params.get('defilement') === 'true'; // 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 + // Alterner toutes les 2 secondes entre type+numéro et statut const [showStatus, setShowStatus] = useState(false); + // Afficher la cause de perturbation 5s, cacher 5s (cycle indépendant) + const [showInfoCause, setShowInfoCause] = useState(true); // État pour l'heure actuelle, mise à jour chaque seconde pour la logique d'affichage du quai const [currentTime, setCurrentTime] = useState(new Date()); @@ -123,6 +97,38 @@ export default function ClassiquesDeparts(){ // debug des données brutes renvoyées par Supabase const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] }); + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent + useEffect(() => { + let mounted = true; + const fetchStationType = async () => { + try { + let fetchedType = 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; + } 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; + }); + fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + } + } + if (mounted) { + setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + } + } catch (e) { /* ignore */ } + }; + if (supabase) { + fetchStationType(); + } + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + // --- Helpers pour perturbations (définis ici pour éviter use-before-define dans useEffect) --- const normalizeName = useCallback((s) => { if (!s) return ''; @@ -252,12 +258,19 @@ export default function ClassiquesDeparts(){ }, []); // retourne le threshold (en minutes) pour afficher le quai selon le type de gare - const thresholdForStationType = useCallback((type) => { - if (!type) return 0; - const t = String(type).trim().toLowerCase(); - if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures - if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes - return 0; + const thresholdForStationType = useCallback((typeGare) => { + const tG = String(typeGare || '').trim().toLowerCase(); + + // Vérification du type de gare + if (tG === 'interurbaine' || tG.includes('interurb')) { + return 360; // 6 heures + } + if (tG === 'ville' || tG.includes('ville')) { + return 30; // 30 minutes + } + + // par défaut, 30 minutes + return 30; }, []); const formatTimeFromHM = useCallback((totalMinutes) => { @@ -354,81 +367,172 @@ export default function ClassiquesDeparts(){ }, [buildPerturbMap, normalizeName, debug]); + // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { - const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; - try { debug('computeStatusForHoraireAtStop', { id: horaire && horaire.id, numero: horaire && (horaire.numero_train || horaire.numero), stationId, stationName, found: found.length }); } catch (e) {} + 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; + 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 maxDelay = null; + let isSuppressed = false; + let causeDetails = ''; - for (const p of found) { - // date filter: if perturbation carries explicit dates or jours_perturbation, check they match horaireYmd - try { - if (horaireYmd) { - const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); - const db = toYMD(p.date_debut || p.start_date); - const df = toYMD(p.date_fin || p.end_date); - if (pDate && pDate !== horaireYmd) { debug('perturbation date mismatch', p.id); continue; } - if (db && df && !(horaireYmd >= db && horaireYmd <= df)) { debug('perturbation date-range mismatch', p.id); continue; } - } - } catch (e) { /* ignore date parse errors -> assume applies */ } + 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) {} - // also check jours_perturbation (may be inside perturbations_arrets wrapper) - try { - const joursSet = getPerturbationJours(p); - if (joursSet && horaireYmd) { - if (!joursSet.has(horaireYmd)) { debug('perturbation jours mismatch', p.id); 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 parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; - const arrets = parsedObj.arrets || []; - let matched = false; - for (const a of arrets) { - 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; - const na = normalizeName(aName || ''); - const ns = normalizeName(stationName || ''); - const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); - if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { - matched = true; - const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); - if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { - const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; - if (maxDelay == null || d > maxDelay) maxDelay = d; - } - } - } + const trainStops = getStops(horaire); + let trainStopMatch = false; - if (!matched) { - // top-level perturbation that references the horaire/numero - 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; - if (appliesByRef || appliesByNumber || isGlobal) { - const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); - if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { - const d = parseDelayMinutesFromArret(p) || 0; - if (maxDelay == null || d > maxDelay) maxDelay = d; - } - } - } - } + 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; + }; - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; - return { label: "à l'heure", detail: '', delayMinutes: null }; - }, [findPerturbsForHoraireAndStop, parsePerturbArretsObject, getPerturbationJours, parseDelayMinutesFromArret, normalizeName, debug]); + 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; + } + if (p.cause_details) { + causeDetails = p.cause_details; + } + } + } + + 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]); // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée - const applyPerturbationsToHoraires = useCallback((list = [], perturbList = perturbations, displayStationId = null, displayStationName = null, serviceDate = null) => { + const applyPerturbationsToHoraires = useCallback((list = [], perturbList = [], displayStationId = null, displayStationName = null, serviceDate = null) => { const map = buildPerturbMap(perturbList || []); // fallback to global display station context const targetStationId = displayStationId || gareIdParam || null; @@ -449,22 +553,6 @@ export default function ClassiquesDeparts(){ // compute display time using delay at the display station if any const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate); let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); - if (displayStatus && typeof displayStatus.delayMinutes === 'number' && !isNaN(displayStatus.delayMinutes)) { - if (typeof horaire._depHM === 'number') { - displayTime = formatTimeFromHM(horaire._depHM + displayStatus.delayMinutes); - } else { - const tryParse = (s) => { - if (!s) return null; - 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 base = tryParse(horaire._rawTime || horaire.heure_depart || horaire.time || horaire.heure || ''); - if (typeof base === 'number') displayTime = formatTimeFromHM(base + displayStatus.delayMinutes); - } - } horaire._stopsStatus = stopsStatus; // prefer platform from stop data (quai) if available const stopForStation = (stops || []).find(s => { @@ -472,20 +560,36 @@ export default function ClassiquesDeparts(){ 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 || ns.includes(target) || target.includes(ns))); + return (targetStationId && String(sid) === String(targetStationId)) || (targetStationName && ns === target); }) || null; horaire._status = displayStatus; horaire._displayTime = displayTime; - horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.platform || stopForStation.quay)) || horaire.platform || ''; + // utiliser quai en priorité + horaire._displayPlatform = (stopForStation && (stopForStation.quai || stopForStation.quai_reel || stopForStation.platform || stopForStation.quay)) || horaire.quai || horaire.quai_reel || horaire.platform || ''; return horaire; }); - }, [buildPerturbMap, computeStatusForHoraireAtStop, formatTimeFromHM, perturbations, gareDisplay, gareIdParam, getStops, normalizeName]); + }, [buildPerturbMap, computeStatusForHoraireAtStop, gareDisplay, gareIdParam, getStops, normalizeName]); useEffect(() => { - const id = setInterval(() => setShowStatus(s => !s), 3000); + // Alternance toutes les 4 secondes (type+numéro ↔ statut) + const id = setInterval(() => setShowStatus(s => !s), 4000); return () => clearInterval(id); }, []); + // Cycle 5s visible / 5s caché pour la cause de perturbation + useEffect(() => { + let timeoutId; + const scheduleCycle = (currentlyVisible) => { + timeoutId = setTimeout(() => { + setShowInfoCause(!currentlyVisible); + scheduleCycle(!currentlyVisible); + }, 5000); + }; + setShowInfoCause(true); + scheduleCycle(true); + return () => clearTimeout(timeoutId); + }, []); + // Récupération Supabase — maintenant avec polling pour synchroniser sans recharger la page useEffect(() => { let mounted = true; @@ -554,29 +658,6 @@ export default function ClassiquesDeparts(){ // stocker la liste complète (non filtrée) pour associer horaire_id -> numero_train if (mounted) setAllHoraires(parsedHoraires || []); - - // Récupérer le type de gare depuis la table `gares` si possible - try { - let fetchedType = 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; - } else if (gareDisplay) { - const needle = `%${gareDisplay}%`; - try { - const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').or(`nom.ilike.${needle},name.ilike.${needle}`).limit(1); - if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; - } catch (e) { - try { - const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', needle).limit(1); - if (gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; - } catch (err) { /* ignore */ } - } - } - if (mounted) { - setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); - } - } catch (e) { /* ignore errors reading gares table */ } // Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée // (soit comme gare_depart, soit comme un arrêt intermédiaire dans `stops` / `gares_desservies`) // Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus) @@ -586,11 +667,11 @@ export default function ClassiquesDeparts(){ displayHoraires = displayHoraires.filter(h => { // Exclure si la gare ciblée est la gare d'arrivée finale const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); - if (destName && (destName === normalized || destName.includes(normalized) || normalized.includes(destName))) return false; + 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 && (departName === normalized || departName.includes(normalized) || normalized.includes(departName))) return true; + if (departName === normalized) return true; // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max) const stopsList = getStops(h); @@ -607,7 +688,7 @@ export default function ClassiquesDeparts(){ 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 || stopName.includes(normalized) || normalized.includes(stopName)) return true; + if (stopName === normalized) return true; } } return false; @@ -820,23 +901,14 @@ export default function ClassiquesDeparts(){ return false; }; - const isSubstitution = (h) => { - if (!h) return false; - // nouvelle colonne spécifique - if (h.substitution_disponible === true) return true; - // anciennes variantes - const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true; - const subStr = (typeof h.substitution === 'string' && ['true','1','yes','y','t'].includes(h.substitution.toLowerCase())); - const typeMatch = h.type_train && /subst|substitut|remplac/i.test(String(h.type_train)); - const commentMatch = h.comment && /subst|substitut|remplac/i.test(String(h.comment)); - return Boolean(subFlag || subStr || typeMatch || commentMatch); - }; + // compute linkage and travaux applicability per horaire const enriched = enrich.map(h => { let linked = false; let appliesToday = false; let appliesTomorrow = false; + let linkedTravaux = null; for (const t of travauxList) { // Extraire identifiants de ligne (différentes variantes possibles) const tLine = t.ligne_id || t.ligne || t.line_id || t.line || t.ligneId || t.lineId; @@ -856,12 +928,19 @@ export default function ClassiquesDeparts(){ } 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; - if (!appliesToday && travauxApplies(t,h,today)) appliesToday = true; - if (!appliesTomorrow && travauxApplies(t,h,tomorrow)) appliesTomorrow = true; - if (linked && appliesToday && appliesTomorrow) break; + let matchRef = ref && (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero)); + if (matchRef) linked = true; + + const appT = travauxApplies(t,h,today); + const appY = travauxApplies(t,h,tomorrow); + if (appT) appliesToday = true; + if (appY) appliesTomorrow = true; + + if (matchRef || appT || appY) { + linkedTravaux = t; + } } - return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow }; + return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow, _linkedTravauxObj: linkedTravaux }; }); // Build today and tomorrow candidate lists @@ -869,7 +948,7 @@ export default function ClassiquesDeparts(){ 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) return false; + if (h._depHM < todayHM - 1) return false; // if travaux apply at this horaire today, hide regular horaires if (h._travauxAppliesToday && !isSubstitution(h)) return false; @@ -910,21 +989,21 @@ export default function ClassiquesDeparts(){ // Construire un index local des perturbations à partir des données fraîchement récupérées // Filtrer pour ne garder que les perturbations applicables aujourd'hui (éviter d'appliquer les perturbations d'hier) - const todayYmd = new Date().toLocaleDateString('en-CA'); + 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 || p.start_date || p.date_debut); + 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); - // si pDate fourni et ne correspond pas à aujourd'hui -> ignorer - if (pDate && pDate !== todayYmd) return false; - // si fourche de dates fournie et aujourd'hui n'est pas dans l'intervalle -> ignorer - if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; - // si jours_perturbation fourni -> vérifier qu'aujourd'hui en fait partie + // si pDate fourni et ne correspond pas à la date de service -> ignorer + if (pDate && pDate !== serviceDateStr) return false; + // si fourche de dates fournie et la date de service n'est pas dans l'intervalle -> ignorer + if (db && df && !(serviceDateStr >= db && serviceDateStr <= df)) return false; + // si jours_perturbation fourni -> vérifier que la date de service en fait partie try { const joursSet = getPerturbationJours(p); - if (joursSet && !joursSet.has(todayYmd)) return false; + if (joursSet && !joursSet.has(serviceDateStr)) return false; } catch (e) { // ignore parse errors and keep perturbation } @@ -952,8 +1031,8 @@ export default function ClassiquesDeparts(){ // 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)); + // 10 lignes en mode normal, 20 en mode défilement + setHoraires((annotated || []).slice(0, defilementMode ? 20 : 10)); setPerturbations(filteredPerturb || []); setTravaux(travauxData || []); } catch (err) { @@ -972,124 +1051,223 @@ export default function ClassiquesDeparts(){ const intervalId = setInterval(fetchData, intervalMs); return () => { mounted = false; clearInterval(intervalId); }; - }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]); + }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours, defilementMode]); // Helpers pour perturbations (memoized plus haut) + // Création des lignes vides pour créer l'espace + const emptyRows = Array.from({ length: 10 }).map((_, i) => ({ _isEmpty: true, id: `empty-${i}` })); + + // Liste de lignes à rendre (horaires + espace + horaires + espace en mode défilement pour boucle infinie) + const rowsToRender = defilementMode + ? [...horaires, ...emptyRows, ...horaires, ...emptyRows] + : horaires; + + // Calcul dynamique de l'animation CSS + useEffect(() => { + if (defilementMode && horaires.length > 0) { + // Nombre de lignes de données + const dataRows = horaires.length; + // 10 lignes vides + const emptyRowsCount = 10; + + // Temps pour défiler les données (2s par ligne au lieu de 1s) + const dataScrollTime = dataRows * 2; + // Temps pour défiler l'espace vide (2s par ligne) + const emptyScrollTime = emptyRowsCount * 2; + // Temps de pause de 10 secondes + const pauseTime = 10; + + // Durée totale d'un cycle (défilement + pause) + const totalAnimationTime = dataScrollTime + emptyScrollTime + pauseTime; + + // Calcul des pourcentages pour l'animation + // On commence à 0 + // À la fin du défilement des données, on a défilé de (dataRows / (totalCycleRows * 2)) * 100% de la hauteur totale + // À la fin du défilement de l'espace, on a défilé de (totalCycleRows / (totalCycleRows * 2)) * 100% (soit 50%) + // Ensuite on fait une pause pendant 10 secondes (donc le % de temps avance, mais pas la translation) + + const scrollEndPercent = ((dataScrollTime + emptyScrollTime) / totalAnimationTime) * 100; + + const styleSheet = document.createElement("style"); + styleSheet.innerText = ` + @keyframes customScrollMarquee { + 0% { transform: translateY(0); } + ${scrollEndPercent}% { transform: translateY(-50%); } + 100% { transform: translateY(-50%); } + } + .aff-classiques-departs__scroll-track { + animation: customScrollMarquee ${totalAnimationTime}s linear infinite !important; + } + `; + document.head.appendChild(styleSheet); + + return () => { + document.head.removeChild(styleSheet); + }; + } + }, [defilementMode, horaires]); + + + const renderRow = (h, idx) => { + if (h._isEmpty) { + return
  • ; + } + + const horaire = h || {}; + const op = horaire.type_train || horaire.op || ''; + const isCarTER = (op === 'Car TER' || op === 'Autocar'); + const time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); + const destination = horaire.gare_arrivee_nom || ''; + const platform = (horaire._displayPlatform || '').toString().trim(); + const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null); + const stationName = 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 === 'supprimé'; + const isDelayed = status && status.label && String(status.label).startsWith('retardé'); + + let showPlatform = !isSuppressed && Boolean(platform); + if (showPlatform) { + const nowHM = currentTime.getHours()*60 + currentTime.getMinutes(); + const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); + const threshold = thresholdForStationType(stationType); + if (typeof sched === 'number') { + let diff = sched - nowHM; + // Gérer le passage à minuit + if (diff < -720) { // e.g., now 23:00, sched 01:00, diff is ~ -22 hours + diff += 1440; // treat as next day + } + showPlatform = (diff >= 0 && diff <= threshold); + } else { + showPlatform = false; + } + } + + const isSub = isSubstitution(horaire); + const infoText = isSub + ? (horaire.motif_substitution && horaire.motif_substitution !== 'TRAVAUX' && horaire.motif_substitution !== 'Travaux.' ? horaire.motif_substitution : 'Autocar cour de la gare Plateforme 14') + : (status && status.causeDetails ? status.causeDetails : ''); + const trainNumber = horaire.numero_train || horaire.numero || horaire.number || ''; + + return ( +
  • + + {/* COL 1 : Logo opérateur */} +
    + {isCarTER ? ( +
    + SNCF Voyageurs +
    + ) : ( + + )} +
    + + {/* COL 2 : Type + Numéro (4s) OU Statut (4s) */} +
    + {showStatus ? ( + isSuppressed ? ( +
    supprimé
    + ) : isDelayed ? ( +
    +
    retard
    +
    {status.delayMinutes} min.
    +
    + ) : ( +
    à l'heure
    + ) + ) : ( + <> +
    {op}
    +
    {trainNumber}
    + + )} +
    + + {/* COL 3 : Heure (grande, jaune) */} +
    +
    {time}
    +
    + + {/* COL 4 : Destination + Info cause (slide-in 5s/5s) */} +
    + + {showInfoCause && infoText ? ( +
    +
    +
    |
    +
    {infoText}
    +
    +
    + ) : null} +
    + + {/* COL 5 : Quai OU icône bus */} +
    + {isSub || isCarTER ? ( + Car + ) : showPlatform ? ( +
    +
    {platform}
    +
    + ) : null} +
    + +
  • + ); + }; + return (
    - {/* nom de la gare configurable via ?gare=Nom-de-la-gare */} + + {/* Watermark "départs" — diagonal droite */}
    départs
    -
      - {horaires.length === 0 ? ( -
    • -
      Aucun horaire disponible.
      + + {defilementMode ? ( + /* ── MODE DÉFILEMENT : marquee vertical infini ── */ +
      + {horaires.length === 0 ? ( +
        +
      • +
        Aucun horaire disponible.
        +
      • +
      + ) : ( +
        + {rowsToRender.map((h, idx) => renderRow(h, idx))} +
      + )} +
      + ) : ( + /* ── MODE NORMAL : 10 lignes statiques ── */ +
        + {horaires.length === 0 ? ( +
      • +
        Aucun horaire disponible.
        lignes brutes renvoyées : {rawDebug.count}
        {rawDebug.count > 0 && rawDebug.sample && rawDebug.sample.length > 0 ? ( -
        -				{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).slice(0,1000)}{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).length > 1000 ? '…(truncated)' : ''}
        -			  
        - ) : null} -
      • - ) : horaires.map((h, idx) => { - const horaire = h || {}; - const op = horaire.type_train || horaire.op || ''; - const number = horaire.numero_train || horaire.number || horaire.numero || ''; - // Use enriched display time (computed during fetch) — fallback to formatting raw fields - let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || ''); - - const destination = horaire.gare_arrivee_nom || ''; - const platform = (horaire._displayPlatform || horaire.platform || '').toString().trim(); - // attachedPerturbs/rowObj previously used for mocked status; status is now computed from perturbations per stop - - // determine station context (the display station) — prefer explicit gareIdParam/gareDisplay if provided - 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); - - // prefer precomputed status and display time (synchronisé depuis la BDD) — sinon calculer à la volée - const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations)); - - if (status && status.label === 'supprimé' && showStatus) { - time = ''; - } - // if the status is 'supprimé' and the component is currently showing the status (showStatus toggles every 3s), hide the time - if (status && status.label === 'supprimé' && showStatus) { - time = ''; - } else if (!horaire._displayTime && status && typeof status.delayMinutes === 'number' && !isNaN(status.delayMinutes)) { - // if we have _depHM (minutes since midnight) use it - if (typeof horaire._depHM === 'number') { - time = formatTimeFromHM(horaire._depHM + status.delayMinutes); - } else { - // fallback: try to parse original time string - const tryParse = (s) => { - if (!s) return null; - 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 base = tryParse(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); - if (typeof base === 'number') time = formatTimeFromHM(base + status.delayMinutes); - } - } - const statusClass = status.label === 'supprimé' ? 'aff-classiques-departs__train-meta--suppressed' : (status.label && String(status.label).startsWith('retardé') ? 'aff-classiques-departs__train-meta--delayed' : ''); - - // Ne pas afficher le quai si le train est supprimé ou si aucun quai n'est défini - let showPlatform = !(status && status.label === 'supprimé') && Boolean(platform); - if (showPlatform) { - const nowHM = currentTime.getHours()*60 + currentTime.getMinutes(); // Use currentTime - const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || ''); - const threshold = thresholdForStationType(stationType); - if (typeof sched === 'number' && typeof threshold === 'number' && threshold > 0) { - // n'afficher le quai que si l'heure courante est au moins (scheduled - threshold) - showPlatform = (nowHM >= (sched - threshold)); - } - } - - return ( -
      • -
        - -
        -
        - {showStatus ? ( -
        -
        {status.label}
        -
        {status.detail}
        -
        - ) : ( -
        -
        {op}
        -
        {number}
        -
        - )} -
        -
        -
        {time}
        -
        -
        -
        {destination}
        -
        - {showPlatform ? ( -
        -
        -
        {platform}
        -
        -
        +
        +				  {JSON.stringify(rawDebug.sample.slice(0,2), null, 2).slice(0,1000)}
        +				  {JSON.stringify(rawDebug.sample.slice(0,2), null, 2).length > 1000 ? '…(truncated)' : ''}
        +				
        ) : null}
      • - ); - })} -
      - {/* Footer visuel de l'afficheur — barre foncée en bas */} -
      -
      - + ) : horaires.map((h, idx) => renderRow(h, idx))} +
    + )} -
    -
    + {/* Horloge — superposée en bas à droite */} +
    + +
    ) -} +} \ No newline at end of file diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.css b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.css index 7e50c87..ac29c06 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.css +++ b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.css @@ -1,475 +1,337 @@ - - -.achemine-font-face { - /* helper placeholder (no output) */ +@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_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; -} -.aff-quais__left--strict { - width: 100%; - max-width: 0; - min-width: 700px; - background: var(--white); - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - /* Remonter l'ensemble du contenu vers le bas pour laisser la place au logo en haut */ - padding: 140px 32px 24px 32px; - box-sizing: border-box; - position: relative; - /* leave space at bottom for the footer (height 86px) */ - height: calc(100vh - 86px); - overflow: hidden; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - box-shadow: none; -} -.aff-quais__left--strict, -.aff-quais__left--strict * { - font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; - /* Ensure the left strict zone text uses the intended color (fallback to green-dark) */ - color: #2d522c; -} -.aff-quais__type-logo--strict{ - position: absolute; - top: 18px; - /* positionné à gauche dans la zone blanche (à droite du logo opérateur) */ - /* tout à gauche de la zone blanche */ - left: 32px; - width: 120px; - height: auto; - object-fit: contain; - z-index: 5; - pointer-events: none; -} -.aff-quais__sncf-logo--strict { - width: 86px; - height: auto; - margin-bottom: 18px; - margin-left: 0; - margin-top: 0; - position: absolute; - top: 18px; - /* placé à droite du logo du type */ - left: 140px; - z-index: 4; -} -.aff-quais__watermark { - position: absolute; - /* move watermark further to the right */ - right: -180px; - top: 70%; - transform: translateY(-50%) rotate(-90deg); - /* police agrandie de 200% */ - font-size: 200px; - color: var(--watermark-green); - opacity: 0.07; - font-weight: 800; - letter-spacing: 6px; - pointer-events: none; - user-select: none; - z-index: 0; -} -.aff-quais__row--time-status { - display: flex; - align-items: flex-end; - gap: 18px; - margin-bottom: 18px; - z-index: 1; -} -.aff-quais__time--strict { - color: var(--green-dark); - font-weight: 800; - /* police agrandie de 200% */ - font-size: 112px; - line-height: 1; - letter-spacing: 0.5px; - margin-right: 12px; + font-family: 'Achemine'; + src: url('/public/fonts/achemine_bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; } -/* Wrapper that contains both the big time and the smaller status when alternating. - Keeps the layout width/height stable so alternating does not reflow the surrounding text. */ -.aff-quais__time-status-wrapper { - position: relative; - display: inline-block; /* width is determined by the time element in normal flow */ - height: 112px; /* match .aff-quais__time font-size so status can be aligned at bottom */ - vertical-align: bottom; - overflow: visible; +body, html { + margin: 0; + padding: 0; + background-color: #006330; /* Green theme */ + overflow: hidden; } -.aff-quais__time-status-wrapper .aff-quais__time--strict { - position: relative; /* keep in flow to define wrapper width */ - display: block; - top: 0; - left: 0; - transition: opacity 180ms linear; +.aff-quais-arrivees-redesign__container { + display: flex; + flex-direction: row; + width: 100vw; + height: 100vh; + position: relative; + font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; + background-color: #006330; + color: #fff; + overflow: hidden; } -.aff-quais__time-status-wrapper .aff-quais__time-status--strict { - position: absolute; /* overlay on the same horizontal position */ - left: 0; - right: 0; - bottom: 0; /* align to bottom similar to previous align-self:flex-end */ - transition: opacity 180ms linear; +.aff-quais-arrivees-redesign__empty { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; } -.aff-quais__time-status-wrapper .hidden { opacity: 0; pointer-events: none; } -.aff-quais__time-status-wrapper .visible { opacity: 1; } -.aff-quais__time-status--strict { - color: var(--accent-yellow); - /* police agrandie de 200% */ - font-size: 44px; - font-weight: 400; - opacity: 1; - align-self: flex-end; - margin-bottom: 14px; -} -.aff-quais__destination--strict { - margin-top: 10px; - margin-bottom: 0; - color: var(--green-dark); - z-index: 1; -} -.aff-quais__destination-line1--strict { - /* police agrandie de 200% */ - font-size: 76px; - font-weight: 800; - line-height: 1.1; -} -.aff-quais__destination-line2--strict { - /* police agrandie de 200% */ - font-size: 76px; - font-weight: 800; - line-height: 1.1; -} -.aff-quais__train-id--strict { - margin-top: 12px; - color: var(--green-dark); - font-weight: 700; - /* police agrandie de 200% */ - font-size: 44px; - letter-spacing: 0.5px; - z-index: 1; -} -.aff-quais__train-number--strict { - color: var(--green-dark); - font-weight: 400; - /* police agrandie de 200% */ - font-size: 44px; -} -.aff-quais__quai{ - margin-top: 8px; - color: var(--green-dark); - font-weight:800; - background: rgba(255,255,255,0.08); - padding:6px 10px; - border-radius:6px; - display:inline-block; +.aff-quais-arrivees-redesign__empty-text { + font-size: 6rem; + font-weight: 700; + color: #ffffff; + text-align: center; } -/* central divider */ -.aff-quais__divider{ - width:18px; - background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.18)); - display:flex; - justify-content:center; - align-items:flex-start; - padding-top:36px; -} -.aff-quais__divider:before{ - content:''; - width:6px; - background: transparent; - box-shadow: 0 18px 0 0 #f0e34f, 0 54px 0 0 #f0e34f, 0 90px 0 0 #f0e34f, 0 126px 0 0 #f0e34f, 0 162px 0 0 #f0e34f, 0 198px 0 0 #f0e34f; - display:block; - border-radius:4px; +/* ========================================================= + LEFT PANEL + ========================================================= */ +.aff-quais-arrivees-redesign__left { + position: absolute; + top: 0; + left: 0; + width: 35%; + bottom: 80px; + background-color: #f3eef4; /* Lilac/Light Grey background */ + box-sizing: border-box; + padding: 30px 40px; + display: flex; + flex-direction: column; + overflow: hidden; } -/* Right panel with stops */ -.aff-quais__right{ - flex:1; - background: linear-gradient(180deg,var(--green) 0%, var(--green-dark) 100%); - display:flex; - align-items:center; - box-sizing:border-box; - padding: 24px 36px; -} -.aff-quais__stops-box{ - width:100%; - max-width:820px; - margin-left:24px; -} -.aff-quais__stops-list{ - list-style:none; - margin:0; - padding:0; - width:100%; -} -.aff-quais__stop{ - display:flex; - align-items:center; - gap:18px; - padding: 10px 16px; - color:#fff; - font-size:30px; - line-height:1.1; -} -.aff-quais__bullet{ - width:14px; - height:14px; - background: #bbd258; /* yellow bullet */ - border-radius:50%; - display:inline-block; - box-shadow: 0 0 0 4px rgba(240,227,79,0.06) inset; -} -.aff-quais__stop--active{ - background: rgba(155,123,224,0.15); - border-radius:6px; -} -.aff-quais__stop--active .aff-quais__bullet{ - background:#9b7be0; /* purple for final */ -} -.aff-quais__stop-text{ - flex:1; - padding-left:6px; +.aff-quais-arrivees-redesign__sncf-logo { + width: 90px; + height: auto; + margin-bottom: 25px; + z-index: 2; } -/* --- STRICT STYLES FOR RIGHT PANEL --- */ - -.aff-quais__right--strict { - flex: 1; - /* use arrivals green gradient */ - background: linear-gradient(180deg, var(--green) -100%, var(--green-dark) 100%); - display: flex; - align-items: flex-start; - justify-content: flex-start; - padding: 48px 0 48px 0; - min-width: 0; - /* leave space for footer */ - height: calc(100vh - 86px); - box-sizing: border-box; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - box-shadow: none; - position: relative; /* anchor for the stops rail */ +.aff-quais-arrivees-redesign__time-status { + display: flex; + align-items: baseline; + gap: 15px; + margin-bottom: 5px; + z-index: 2; } -.aff-quais__stops-list--strict { - list-style: none; - margin: 0; - /* variables pour contrôler la colonne de pastilles */ - --rail-gap: 72px; /* espacement vertical entre pastilles (ajuster si nécessaire) */ - --rail-offset: 24px; /* décalage vertical initial pour aligner la première pastille */ - --rail-left: 28px; /* position X du rail inside right panel */ - --rail-width: 12px; /* largeur du rail/pastille */ - --ul-padding: 56px; /* padding-left actuel de la liste */ - /* laisser un espace modéré à gauche pour la colonne de points */ - padding: 0 0 0 var(--ul-padding); - width: 100%; - max-width: 600px; - position: relative; +.aff-quais-arrivees-redesign__time { + color: #006330; /* Green theme text */ + font-weight: 700; + font-size: 7.5rem; + line-height: 1; } -/* élément rail explicite (utilisé pour afficher la colonne de pastilles) */ -.aff-quais__stops-rail{ - position: absolute; - /* position relative to the right panel container */ - left: calc(var(--rail-left) - 8px); - top: 0; - bottom: 0; - /* make rail clearly visible and wide enough */ - width: 30px; - z-index: 2; - pointer-events: none; - /* base color: same as destination rectangle */ - background-color: rgba(255,255,255,0.06); - overflow: visible; +.aff-quais-arrivees-redesign__status { + color: #006330; /* Green theme status */ + font-weight: 700; + font-size: 2.4rem; + line-height: 1; } -.aff-quais__rail-dot{ - display: block; - position: absolute; - left: 50%; - transform: translate(-50%, -50%); - /* size is set dynamically from JS to match rail width */ - width: auto; - height: auto; - border-radius: 50%; - background: var(--accent-yellow); - box-shadow: 0 0 0 4px rgba(255,230,102,0.06) inset; +.aff-quais-arrivees-redesign__destination { + color: #006330; /* Green theme destination (origin) */ + font-weight: 700; + font-size: 5.5rem; /* Slightly smaller to fit 2 lines if needed */ + line-height: 1.1; + margin-bottom: 5px; + z-index: 2; + white-space: normal; /* Allow wrapping */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-height: 13rem; /* Prevent overflowing panel */ } -.aff-quais__stop--strict { - display: flex; - align-items: center; - /* agrandi de 150% */ - font-size: calc(2.1rem * 1.5); - color: #fff; - font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; - font-weight: 400; - /* espacement augmenté entre les noms des gares */ - margin-bottom: 18px; - line-height: 1.18; - padding: 0 0 0 0; - position: relative; /* each li is positioned so its ::before aligns with the rail */ +.aff-quais-arrivees-redesign__train-info { + display: flex; + align-items: center; + gap: 10px; + z-index: 2; } -.aff-quais__stop-bullet--strict { - /* bullets individuels masqués : on utilise une barre verticale */ - display: none; +.aff-quais-arrivees-redesign__train-type { + color: #006330; /* Green theme train type */ + font-weight: 700; + font-size: 2.2rem; + letter-spacing: 0.5px; } -/* pastille positionnée pour chaque item, alignée sur la colonne (rail) */ -.aff-quais__stop--strict::before{ - /* disable per-item pseudo pastille — rail handles the dots */ - display: none; +.aff-quais-arrivees-redesign__train-number { + color: #7e8a80; /* Grey/Green train number */ + font-weight: 400; + font-size: 2.2rem; + letter-spacing: 0.5px; } -/* barre verticale de points jaunes pour la liste des gares */ -/* removed ::before — using explicit .aff-quais__stops-rail element instead */ - -.aff-quais__stop-text--strict{ /* ensure text doesn't overlap the rail */ - padding-left: 6px; +.aff-quais-arrivees-redesign__icons { + position: absolute; + bottom: 20px; + left: 40px; + display: flex; + align-items: center; + z-index: 2; } -.aff-quais__status--alert{ - color: #ffe600; - font-weight: 800; +.aff-quais-arrivees-redesign__watermark { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%) rotate(-90deg); + transform-origin: right center; + font-size: 17rem; /* Massive size */ + color: #dbebdf; /* Light green/grey watermark */ + font-weight: 700; + z-index: 1; + pointer-events: none; + user-select: none; + letter-spacing: 1.5rem; + line-height: 1; + white-space: nowrap; } -.aff-quais__stop-text--strict { - display: inline-block; - font-size: inherit; - font-weight: inherit; - color: inherit; +/* ========================================================= + RIGHT PANEL + ========================================================= */ +.aff-quais-arrivees-redesign__right { + position: absolute; + top: 0; + right: 0; + width: 65%; + bottom: 80px; + background-color: #006330; /* Green theme */ + box-sizing: border-box; + padding: 30px 0 30px 0; + overflow: hidden; + display: flex; + flex-direction: column; } -.aff-quais__stop--strict-dest { - font-weight: 800; - background: rgba(255,255,255,0.08); - color: var(--green-dark); - border-radius: 6px; - padding: 2px 32px 2px 0; - margin-left: -45px; - width: auto; - - padding-left: 45px; +.aff-quais-arrivees-redesign__provenance-title { + font-size: 3.4rem; + color: #dbeb1a; /* Yellow provenance header */ + margin-left: 60px; + margin-bottom: 20px; + font-weight: 700; } -/* Ensure the highlighted destination stop text is white for readability */ -.aff-quais__stop--strict-dest .aff-quais__stop-text--strict { - color: #ffffff !important; +.aff-quais-arrivees-redesign__stops-list { + list-style: none; + margin: 0; + padding: 0 0 0 60px; /* Space from left edge */ + width: 100%; + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + gap: 8px; /* Closer spacing between stops */ } -.aff-quais__container--strict.aff-quais__container--arrivees { - display: flex; - flex-direction: row; - width: 100vw; - height: 100vh; - min-height: 0; - min-width: 0; - background: var(--green-dark); - box-sizing: border-box; - overflow: hidden; - /* make container positioned so the absolute footer can be placed relative to it */ - position: relative; +.aff-quais-arrivees-redesign__stop { + display: flex; + align-items: center; + font-size: 3.4rem; + font-weight: 400; + color: #ffffff; + position: relative; + min-height: 42px; + padding-left: 50px; /* Space for rail and bullet */ } -.aff-quais__separator--strict { - width: 14px; - min-width: 14px; - max-width: 14px; - background: var(--green-dark); - /* align separator with the panels which leave room for footer */ - height: calc(100vh - 86px); - display: block; - z-index: 2; +/* Bullet styling */ +.aff-quais-arrivees-redesign__stop-bullet { + position: absolute; + left: 0; /* Centered on 24px rail: (24-16)/2 = 4 */ + margin-left: 4px; + top: 50%; + transform: translateY(-50%); + width: 16px; /* Slightly larger bullet */ + height: 16px; + background-color: #fbec22; + border-radius: 50%; + z-index: 4; } -@media (max-width: 900px) { - .aff-quais__right--strict { - padding: 18px 0 18px 0; - } - /* moins d'espace en haut sur petits écrans */ - .aff-quais__left--strict { padding-top: 80px; } - .aff-quais__stops-list--strict { - padding-left: 12px; - } - .aff-quais__stop--strict { - font-size: 1.1rem; - } - .aff-quais__stop-bullet--strict { - width: 8px; - height: 8px; - margin-right: 8px; - } - .aff-quais__stop--strict-dest { - padding: 1px 6px 1px 0; - border-radius: 4px; - } +.aff-quais-arrivees-redesign__stop-text { + position: relative; + z-index: 4; + white-space: nowrap; + letter-spacing: 0.5px; + padding-left: 10px; /* Space from bullet */ } -/* --- END STRICT STYLES FOR RIGHT PANEL --- */ - -.aff-quais__footer{ - position:absolute; - left:0; - right:0; - bottom:0; - height:86px; - pointer-events:none; -} -.aff-quais__footer-inner{ - display:flex; - justify-content:space-between; - align-items:center; - height:100%; - padding: 0 28px; - background: linear-gradient(to bottom, rgba(0,0,0,0.15), rgba(0,0,0,0.25)); -} -.aff-quais__footer-left{ - color:#fff; - font-weight:700; - font-size:20px; -} -/* CLOCK BOX - unified */ -.aff-quais__clock-box{ - background: var(--green-dark); - color:#fff; - padding: 6px 12px; - border-radius: 6px; - border: 4px solid #ffffff; -} -.aff-quais__clock-main{ - font-size:18px; - font-weight:800; +/* The vertical rail segments drawn behind each bullet */ +.aff-quais-arrivees-redesign__stop::before { + content: ''; + position: absolute; + left: 0; + width: 24px; /* Much wider rail */ + background-color: #8fa993; /* Light green-grey rail color */ + top: -10px; + bottom: -10px; + z-index: 1; } -/* position the clock bottom-right inside a small white rounded box like on the image */ -.aff-quais__footer-right{ - position:relative; -} -.aff-quais__clock{ - position:relative; -} -@media (max-width: 900px){ - .aff-quais__left{width:42%;} - .aff-quais__destination-line1{font-size:36px} - .aff-quais__destination-line2{font-size:28px} - .aff-quais__time{font-size:48px} +/* Rail starts at the first bullet's center */ +.aff-quais-arrivees-redesign__stop:first-child::before { + top: 50%; } +/* Last stop has no rail continuing below */ +.aff-quais-arrivees-redesign__stop:last-child::before { + bottom: 50%; +} + +/* ========================================================= + FOOTER + ========================================================= */ +.aff-quais-arrivees-redesign__footer { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 80px; + background-color: #006330; /* Green theme */ + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + padding: 0 40px; + z-index: 10; +} + +.aff-quais-arrivees-redesign__footer-left { + font-size: 3rem; + font-weight: 700; + color: #ffffff; + font-style: italic; + font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; + letter-spacing: 0.5px; +} + +.aff-quais-arrivees-redesign__footer-right { + display: flex; + align-items: center; +} + +.aff-quais-arrivees-redesign__clock { + border: 2px solid #ffffff; + padding: 4px 14px; + font-size: 3rem; + font-weight: 700; + color: #ffffff; + display: flex; + align-items: center; + gap: 10px; + font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; +} + +.aff-quais-arrivees-redesign__clock-s { + color: #f5a623; +} + +.aff-quais-arrivees-redesign__time--long-text { + font-size: 3.5rem !important; + line-height: 1.2 !important; +} + +.aff-quais-arrivees-redesign__time--suppressed, +.aff-quais-arrivees-redesign__status--suppressed { + color: #ff4c5b !important; +} + +.aff-quais-arrivees-redesign__time--delayed, +.aff-quais-arrivees-redesign__status--delayed { + color: #ff9e43 !important; +} + +/* Disrupted Stop styling */ +.aff-quais-arrivees-redesign__stop-text--suppressed { + text-decoration: line-through; + color: #ff4c5b; + opacity: 0.6; +} + +.aff-quais-arrivees-redesign__stop-text--delayed { + color: #ff9e43; +} + +.aff-quais-arrivees-redesign__stop-status-badge { + font-size: 2.2rem; + font-weight: bold; + margin-left: 10px; +} + +.aff-quais-arrivees-redesign__stop-status-badge--suppressed { + color: #ff4c5b; +} + +.aff-quais-arrivees-redesign__stop-status-badge--delayed { + color: #ff9e43; +} diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js index cbf52b8..7e39763 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js @@ -1,986 +1,896 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesArriveesQuais.css'; -import '../ClassiquesArrivees.css'; - -/* - ClassiquesArriveesQuais.js - Variante "Arrivées - quais" du composant quais. - - Différences principales avec la version départs: - - utilise `heure_arrivee` / `gare_arrivee_*` lorsque disponible pour afficher l'heure et la destination - - watermark affiché: "arrivée" - - styles importés depuis `ClassiquesArriveesQuais.css` qui reprend la palette de `ClassiquesArrivees.css` - - Champs utilisés pour le calcul/affichage: - - horaire.heure_arrivee, horaire.time, horaire.heure - - horaire.gare_arrivee_nom, horaire.gare_arrivee - - stop.heure_arrivee, stop.gare_nom, stop.gare_id - - perturbations: mêmes clés que pour les départs (retard, retard_minutes, perturbations_arrets, etc.) -*/ import supabase from '../../../../lib/supabase'; - -// Hook pour récupérer la map des logos depuis /type/data.json -function useTypeLogos(){ - const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' }); - useEffect(() => { - let mounted = true; - - const load = async () => { - const candidates = ['/type/quais/data-color.json', '/type/data.json']; - for(const url of candidates){ - try{ - const res = await fetch(url); - if(!res.ok) continue; - const data = await res.json(); - if(!mounted) return; - const logos = {}; - if(Array.isArray(data.logos)){ - data.logos.forEach(item => { - // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path - let file = item.file; - if(!file && item.path){ - const parts = String(item.path).split('/'); - file = parts[parts.length-1]; - } - if(!file) return; - const basename = file.replace(/\.[^/.]+$/, ''); - const semantic = basename.replace(/^logo-/, ''); - const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-'); - // Prefer color logos in the 'quais' subfolder if data came from the quais file - if(url.includes('/quais/')){ - logos[key] = `/type/quais/${file}`; - } else { - logos[key] = `/type/${file}`; - } - }); - } - logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png'; - setMap(logos); - return; - }catch(e){ - // try next candidate (ignore error and continue loop) - } - } - // no candidate succeeded — keep fallback - }; - - load(); - return () => { mounted = false; }; - }, []); - return map; -} - -function getTypeLogo(type, TYPE_LOGOS_MAP){ - const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' }; - if(!type) return map.default; - const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-'); - if(map[norm]) return map[norm]; - const keys = Object.keys(map).filter(k => k !== 'default'); - for(const k of keys){ - if(k.includes(norm) || norm.includes(k)) return map[k]; - } - return map.default; -} - 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}
    -
    - ); + 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 ClassiquesArriveesQuais(){ - const typeLogos = useTypeLogos(); +export default function ClassiquesArriveesQuais() { + const mock = { + time: '16h44', + status: "à l'heure", + trainType: 'Fluo', + trainNumber: '891811', + destinationBig: 'Seurre', + destinationStation: '', + stops: [ + 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse' + ], + }; - const rightRef = useRef(null); - const stopsListRef = useRef(null); - const railRef = useRef(null); + const [selectedHoraire, setSelectedHoraire] = useState(null); + const [loadingHoraire, setLoadingHoraire] = useState(false); + const [, setHoraireError] = useState(null); + const [, setCandidatesCount] = useState(0); + const [stationType, setStationType] = useState(null); + const [currentTime, setCurrentTime] = useState(new Date()); + const [alternateDelayPhase, setAlternateDelayPhase] = useState(true); + const [alternateCancelPhase, setAlternateCancelPhase] = useState(true); - // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée) - const mock = { - time: '16h44', - status: "à l'heure", - trainType: 'Fluo', - trainNumber: '891811', - destinationBig: 'Seurre', - destinationStation: '', - stops: [ - 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse' - ], + useEffect(() => { + const id = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + const interval = setInterval(() => { + setAlternateDelayPhase(prev => !prev); + }, 5000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const interval = setInterval(() => { + setAlternateCancelPhase(prev => !prev); + }, 5000); + return () => clearInterval(interval); + }, []); + + const [perturbations, setPerturbations] = useState([]); + const [, setTravaux] = useState([]); + + const weekdayNameForColumn = useCallback((d) => { + const i = d.getDay(); + switch(i) { + case 1: return 'circule_lundi'; + case 2: return 'circule_mardi'; + case 3: return 'circule_mercredi'; + case 4: return 'circule_jeudi'; + case 5: return 'circule_vendredi'; + case 6: return 'circule_samedi'; + case 0: return 'circule_dimanche'; + default: return null; + } + }, []); + + const isPublicHoliday = useCallback((d) => { + const y = d.getFullYear(); + const m = d.getMonth() + 1; + const day = d.getDate(); + const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const holidays = [ + `${y}-01-01`, + `${y}-05-01`, + `${y}-05-08`, + `${y}-07-14`, + `${y}-08-15`, + `${y}-11-01`, + `${y}-11-11`, + `${y}-12-25`, + ]; + if (y === 2026) { + holidays.push('2026-04-06'); + holidays.push('2026-05-14'); + holidays.push('2026-05-25'); + } + return holidays.includes(s); + }, []); + + const circulatesOn = useCallback((h, d) => { + if (isPublicHoliday(d)) { + const v = h['circule_jours_feries']; + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v === 1; + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; + } + return (typeof v === 'undefined' || v === null) ? false : Boolean(v); + } + const col = weekdayNameForColumn(d); + if (!col) return true; + const v = h[col]; + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v === 1; + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; + } + return (typeof v === 'undefined' || v === null) ? true : Boolean(v); + }, [isPublicHoliday, weekdayNameForColumn]); + + const normalizeName = useCallback((s) => { + if (!s) return ''; + try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); } + }, []); + + const parseTimeHM = useCallback((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 formatTimeNoSeconds = useCallback((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}h${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')}h${m[2]}`; + const m2 = v.match(/T(\d{2}):(\d{2})/); + if (m2) return `${m2[1]}h${m2[2]}`; + return v.slice(0,5).replace(':','h'); + } + return String(v).slice(0,5).replace(':','h'); + }, []); + + const formatTimeFromHM = useCallback((totalMinutes) => { + if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; + const m = ((totalMinutes % 1440) + 1440) % 1440; + const hh = String(Math.floor(m / 60)).padStart(2, '0'); + const mm = String(m % 60).padStart(2, '0'); + return `${hh}h${mm}`; + }, []); + + const thresholdForStationType = useCallback((type) => { + if (!type) return 0; + const t = String(type).trim().toLowerCase(); + if (t === 'interurbaine' || t.includes('interurb')) return 360; + if (t === 'ville' || t.includes('ville')) return 30; + return 0; + }, []); + + const paramsForThis = new URLSearchParams(window.location.search || ''); + const gareParam = paramsForThis.get('gare'); + const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId'); + const quaiParam = paramsForThis.get('quai'); + + const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null; + + const getStops = (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; + if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets; + const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','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) {} + } + } + return []; + }; + + const safeParseArrets = (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 }; + 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]); - // état pour l'horaire sélectionné - const [selectedHoraire, setSelectedHoraire] = useState(null); - const [loadingHoraire, setLoadingHoraire] = useState(false); - const [horaireError, setHoraireError] = useState(null); - const [candidatesCount, setCandidatesCount] = useState(0); - // type de la gare (récupéré depuis la table gares) - const [stationType, setStationType] = useState(null); - // Heure actuelle mise à jour chaque seconde pour la logique de seuil - const [currentTime, setCurrentTime] = useState(new Date()); + 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; + }, []); - useEffect(() => { - const id = setInterval(() => setCurrentTime(new Date()), 1000); - return () => clearInterval(id); - }, []); - - // perturbations/travaux (récupérées depuis Supabase) - const [perturbations, setPerturbations] = useState([]); - const [, setTravaux] = useState([]); - - // helpers locaux (simples) — similaires à ceux du composant principal - const weekdayNameForColumn = useCallback((d) => { - const i = d.getDay(); - switch(i) { - case 1: return 'circule_lundi'; - case 2: return 'circule_mardi'; - case 3: return 'circule_mercredi'; - case 4: return 'circule_jeudi'; - case 5: return 'circule_vendredi'; - case 6: return 'circule_samedi'; - case 0: return 'circule_dimanche'; - default: return null; - } - }, []); - - const isPublicHoliday = useCallback((d) => { - const y = d.getFullYear(); - const m = d.getMonth() + 1; - const day = d.getDate(); - const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - const holidays = [ - `${y}-01-01`, // Jour de l'an - `${y}-05-01`, // Fête du Travail - `${y}-05-08`, // Victoire 1945 - `${y}-07-14`, // Fête nationale - `${y}-08-15`, // Assomption - `${y}-11-01`, // Toussaint - `${y}-11-11`, // Armistice 1918 - `${y}-12-25`, // Noël - ]; - // Dates variables pour 2026 - if (y === 2026) { - holidays.push('2026-04-06'); // Lundi de Pâques - holidays.push('2026-05-14'); // Ascension - holidays.push('2026-05-25'); // Lundi de Pentecôte - } - return holidays.includes(s); - }, []); - - const circulatesOn = useCallback((h, d) => { - if (isPublicHoliday(d)) { - const v = h['circule_jours_feries']; - if (typeof v === 'boolean') return v; - if (typeof v === 'number') return v === 1; - if (typeof v === 'string') { - const s = v.trim().toLowerCase(); - return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; - } - return (typeof v === 'undefined' || v === null) ? false : Boolean(v); - } - const col = weekdayNameForColumn(d); - if (!col) return true; - const v = h[col]; - if (typeof v === 'boolean') return v; - if (typeof v === 'number') return v === 1; - if (typeof v === 'string') { - const s = v.trim().toLowerCase(); - return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes'; - } - return (typeof v === 'undefined' || v === null) ? true : Boolean(v); - }, [isPublicHoliday, weekdayNameForColumn]); - - const normalizeName = useCallback((s) => { - if (!s) return ''; - try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); } - }, []); - - const parseTimeHM = useCallback((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 formatTimeNoSeconds = useCallback((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}h${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')}h${m[2]}`; - const m2 = v.match(/T(\d{2}):(\d{2})/); - if (m2) return `${m2[1]}h${m2[2]}`; - return v.slice(0,5).replace(':','h'); - } - return String(v).slice(0,5).replace(':','h'); - }, []); - - // ...existing code... - - const formatTimeFromHM = useCallback((totalMinutes) => { - if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; - const m = ((totalMinutes % 1440) + 1440) % 1440; - const hh = String(Math.floor(m / 60)).padStart(2, '0'); - const mm = String(m % 60).padStart(2, '0'); - return `${hh}h${mm}`; - }, []); - - // retourne le threshold (en minutes) pour afficher le quai selon le type de gare - const thresholdForStationType = useCallback((type) => { - if (!type) return 0; - const t = String(type).trim().toLowerCase(); - if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures - if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes - return 0; - }, []); - - // lecture des query params pour gare & quai - const paramsForThis = new URLSearchParams(window.location.search || ''); - const gareParam = paramsForThis.get('gare'); - const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId'); - const quaiParam = paramsForThis.get('quai'); - const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true'); - const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null; - - // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs) - const getStops = (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; - if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets; - const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','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 []; - }; - - // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) --- - const safeParseArrets = (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 }; - 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 = []) => { - 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); } + const buildPerturbMap = useCallback((list = []) => { + 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 }); } }); - return idx; - }, [normalizeName]); + } + if ((horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); } + }); + return idx; + }, [normalizeName]); - const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => { - const res = []; - const map = idx || buildPerturbMap(perturbations || []); - 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 (hId != null) { const kh = String(hId); if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); } - if (number != null) { const kn = String(number); if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); } - if (stationId != null) { const ks = String(stationId); if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); } - if (stationName && map.byStopName) { - const key = normalizeName(stationName); - if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation)); - try { - const needle = key; - for (const existingKey of Object.keys(map.byStopName || {})) { - if (existingKey === needle) continue; - if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); } - } - } catch (e) {} + const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => { + const res = []; + const map = idx || buildPerturbMap(perturbations || []); + 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 (hId != null) { const kh = String(hId); if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); } + if (number != null) { const kn = String(number); if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); } + if (stationId != null) { const ks = String(stationId); if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); } + if (stationName && map.byStopName) { + const key = normalizeName(stationName); + if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation)); + try { + const needle = key; + for (const existingKey of Object.keys(map.byStopName || {})) { + if (existingKey === needle) continue; + if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); } } - return Array.from(new Set(res)); - }, [buildPerturbMap, perturbations, normalizeName]); + } catch (e) {} + } + return Array.from(new Set(res)); + }, [buildPerturbMap, perturbations, normalizeName]); - const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { - const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; - 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; - for (const p of found) { - try { - if (horaireYmd) { - const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); - 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) { if (!joursSet.has(horaireYmd)) continue; } - } catch (e) {} - const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; - const arrets = parsedObj.arrets || []; - let matched = false; - for (const a of arrets) { - 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; - const na = normalizeName(aName || ''); - const ns = normalizeName(stationName || ''); - const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); - if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { - matched = true; - const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); - if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { - const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; - if (maxDelay == null || d > maxDelay) maxDelay = d; - } - } - } - if (!matched) { - 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; - if (appliesByRef || appliesByNumber || isGlobal) { - const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); - if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; - if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { - const d = parseDelayMinutesFromArret(p) || 0; - if (maxDelay == null || d > maxDelay) maxDelay = d; - } - } - } + const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => { + const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || []; + 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; + for (const p of found) { + 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; } - if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; - return { label: "à l'heure", detail: '', delayMinutes: null }; - }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName, parsePerturbArretsObject]); - - // Fetch perturbations & travaux from Supabase periodically - useEffect(() => { - let mounted = true; - let inFlight = false; - const fetchPerturb = async () => { - if (!mounted) return; - if (inFlight) return; - inFlight = true; - try { - if (!supabase) return; - const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ - supabase.from('perturbations').select('*'), - supabase.from('travaux').select('*') - ]); - if (!mounted) return; - if (pError || tError) { - // ignore quietly — keep previous data - } else { - // Filtrer les perturbations pour ne conserver que celles valides aujourd'hui - const todayYmd = new Date().toLocaleDateString('en-CA'); - 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 || p.start_date || p.date_debut); - const db = toYMD(p.date_debut || p.start_date); - const df = toYMD(p.date_fin || p.end_date); - if (pDate && pDate !== todayYmd) return false; - if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; - try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(todayYmd)) return false; } catch (e) {} - return true; - } catch (e) { return true; } - }); - setPerturbations(filteredPerturb || []); - setTravaux(travauxData || []); - } - } catch (e) { - // ignore - } finally { - inFlight = false; - } - }; - fetchPerturb(); - const id = setInterval(fetchPerturb, 15000); - return () => { mounted = false; clearInterval(id); }; - }, [getPerturbationJours]); - - // Annotate selectedHoraire with computed status when perturbations change - useEffect(() => { - if (!selectedHoraire || !selectedHoraire.horaire) return; - const horaire = selectedHoraire.horaire; - const stop = selectedHoraire.stop || null; - const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null; - const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null; - try { - const map = buildPerturbMap(perturbations || []); - const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null); - // keep original scheduled time for display (_displayTime) - const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure); - const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null)); - // compute updated time (delayed) but store separately — do not override the original displayed time - let updatedTime = null; - if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') { - updatedTime = formatTimeFromHM(originalBase + status.delayMinutes); - } - setSelectedHoraire(prev => ({ ...(prev||{}), horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime })); - } catch (e) { /* ignore */ } - }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]); - - // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai) - useEffect(() => { - let mounted = true; - async function loadNextForQuai() { - setLoadingHoraire(true); - setHoraireError(null); - setSelectedHoraire(null); - if (!supabase) { - setHoraireError('Supabase non initialisé'); - setLoadingHoraire(false); - return; - } - try { - // Récupérer le type de la gare - let fetchedType = null; - try { - if (gareIdParam) { - const { data: g } = await supabase.from('gares').select('type').eq('gare_id', gareIdParam).single(); - if (g) fetchedType = g.type; - } else if (gareDisplayParam) { - const { data: g } = await supabase.from('gares').select('type').ilike('nom', `%${gareDisplayParam}%`).limit(1).single(); - if (g) fetchedType = g.type; - } - } catch (e) { /* ignore */ } - if (mounted) setStationType(fetchedType); - - const { data: all, error } = await supabase.from('horaires').select('id, *'); - if (error) { - if (mounted) setHoraireError(error.message || String(error)); - setLoadingHoraire(false); - return; - } - if (!mounted) return; - if (!Array.isArray(all) || all.length === 0) { - setLoadingHoraire(false); - return; - } - - const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null; - const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null; - - const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setDate(now.getDate() + 1); - const nowHM = now.getHours()*60 + now.getMinutes(); - - const candidates = []; - - for (const h of all) { - if (!h) continue; - - // Filtrage par jour de circulation - const circulatesToday = circulatesOn(h, now); - const circulatesTomorrow = circulatesOn(h, tomorrow); - if (!circulatesToday && !circulatesTomorrow) continue; - - // build stops list (support multiple field names) - const stops = getStops(h); - - // compute max index for terminus detection if stops contain index - let maxIdx = null; - if (Array.isArray(stops) && stops.length) { - for (const st of stops) { - 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); - } - } - } - - // Match stop by name or id (prefer stop entry over horaire-level arrival) - let stopMatch = null; - if (Array.isArray(stops) && stops.length) { - // first try to match by id if gareIdParam present - if (gareIdParam) { - const gid = String(gareIdParam); - for (const s of stops) { - const sid = s && (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null); - if (sid != null && String(sid) === gid) { stopMatch = s; break; } - } - } - // then try to match by normalized name - if (!stopMatch && normalizedTarget) { - for (const s of stops) { - const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || s.station_name || ''); - const nm = normalizeName(rawName); - if (!nm) continue; - if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { stopMatch = s; break; } - } - } - } - - // if no stopMatch found in stops, check horaire-level gare_arrivee (arrival terminus) - if (!stopMatch) { - const arriveeName = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || ''); - if (arriveeName && normalizedTarget && (arriveeName === normalizedTarget || arriveeName.includes(normalizedTarget) || normalizedTarget.includes(arriveeName))) { - stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee, gare_id: h.gare_arrivee_id || h.gare_arrivee }; - } - } - - if (!stopMatch) continue; // does not arrive at the targeted station - - // determine platform/quai for this stop/horaire - const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; - const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,''); - // Only include horaires that have a platform/quai for the displayed station - if (!normPlatform) continue; - if (targetQuai) { - if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue; - } - - // ensure we have an arrival time - const stopIsObject = stopMatch && typeof stopMatch === 'object'; - const hasArrival = stopIsObject && ( - typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined' - ); - if (stopIsObject && !hasArrival) continue; - - // pick time (arrival) from stopMatch or fallback to horaire-level arrival - const rawTime = (hasArrival ? (stopMatch.heure_arrivee || stopMatch.time || stopMatch.heure || stopMatch.arrival_time || stopMatch.arrival) : (h.heure_arrivee || h.time || h.heure || h.arrival_time || h.arrivee)); - const depHM = parseTimeHM(rawTime); - if (depHM == null) continue; - - // compute minutes until arrival (allow next-day wrap) - let minutesUntil = depHM - nowHM; - if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; - - candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime, circulatesToday, circulatesTomorrow }); - } - - if (!candidates.length) { - setCandidatesCount(0); - setLoadingHoraire(false); - setSelectedHoraire(null); - return; - } - - // split into today (circulatesToday AND depHM > nowHM) and tomorrow (circulatesTomorrow AND (depHM <= nowHM OR not circulatesToday)) - const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); - const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM <= nowHM || !c.circulatesToday)).sort((a,b) => a.depHM - b.depHM); - - let chosen = null; - if (todayCandidates.length > 0) chosen = todayCandidates[0]; - else if (tomorrowCandidates.length > 0) chosen = tomorrowCandidates[0]; - - if (mounted) { - setCandidatesCount(candidates.length); - setSelectedHoraire(chosen || null); - } - } catch (err) { - console.error('Erreur supabase quai', err); - if (mounted) setHoraireError(err.message || String(err)); - } finally { - if (mounted) setLoadingHoraire(false); - } + } catch (e) {} + try { + const joursSet = getPerturbationJours(p); + if (joursSet && horaireYmd) { if (!joursSet.has(horaireYmd)) continue; } + } catch (e) {} + const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null }; + const arrets = parsedObj.arrets || []; + let matched = false; + for (const a of arrets) { + 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; + const na = normalizeName(aName || ''); + const ns = normalizeName(stationName || ''); + const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na)); + if ((stationId != null && String(aId) === String(stationId)) || nameMatches) { + matched = true; + const t = (a.type_perturbation || a.type || '').toString().toLowerCase(); + if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; + if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { + const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0; + if (maxDelay == null || d > maxDelay) maxDelay = d; + } } + } + if (!matched) { + 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 isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef; + if (appliesByRef || appliesByNumber || isGlobal) { + const t = (p.type_perturbation || p.type || '').toString().toLowerCase(); + if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null }; + if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) { + const d = parseDelayMinutesFromArret(p) || 0; + if (maxDelay == null || d > maxDelay) maxDelay = d; + } + } + } + } + if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay }; + return { label: "à l'heure", detail: '', delayMinutes: null }; + }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName, parsePerturbArretsObject]); - loadNextForQuai(); - return () => { mounted = false; }; - }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName, circulatesOn]); + const perturbMap = React.useMemo(() => buildPerturbMap(perturbations || []), [perturbations, buildPerturbMap]); + const serviceDate = React.useMemo(() => { + const todayYmd = new Date().toLocaleDateString('en-CA'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowYmd = tomorrow.toLocaleDateString('en-CA'); + return selectedHoraire && selectedHoraire.isTomorrow ? tomorrowYmd : todayYmd; + }, [selectedHoraire]); - // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present) - const [showTime, setShowTime] = useState(true); - // derive a simple status label string to use in the dependency array (avoids complex expressions) - const _selectedStatusLabel = (() => { - const horaireObj = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const computedLabel = horaireObj && horaireObj._status && horaireObj._status.label ? String(horaireObj._status.label) : null; - const rawStatus = horaireObj && horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status && horaireObj.status.label ? String(horaireObj.status.label) : null)) : null; - return String(computedLabel || rawStatus || mock.status || '').toLowerCase(); - })(); - - useEffect(()=>{ - let id = null; - if (/retard|supprim/.test(_selectedStatusLabel)) { - // ensure we start by showing the time - setShowTime(true); - id = setInterval(() => setShowTime(prev => !prev), 3000); + useEffect(() => { + let mounted = true; + let inFlight = false; + const fetchPerturb = async () => { + if (!mounted) return; + if (inFlight) return; + inFlight = true; + try { + if (!supabase) return; + const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ + supabase.from('perturbations').select('*'), + supabase.from('travaux').select('*') + ]); + if (!mounted) return; + if (pError || tError) { } else { - setShowTime(true); + const todayYmd = new Date().toLocaleDateString('en-CA'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowYmd = tomorrow.toLocaleDateString('en-CA'); + + 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); + + const appliesToToday = (!pDate || pDate === todayYmd) && (!db || !df || (todayYmd >= db && todayYmd <= df)); + const appliesToTomorrow = (!pDate || pDate === tomorrowYmd) && (!db || !df || (tomorrowYmd >= db && tomorrowYmd <= df)); + + if (!appliesToToday && !appliesToTomorrow) return false; + + try { + const joursSet = getPerturbationJours(p); + if (joursSet) { + const hasToday = joursSet.has(todayYmd); + const hasTomorrow = joursSet.has(tomorrowYmd); + if (!hasToday && !hasTomorrow) return false; + } + } catch (e) {} + return true; + } catch (e) { return true; } + }); + setPerturbations(filteredPerturb || []); + setTravaux(travauxData || []); } - return () => { if (id) clearInterval(id); }; - }, [_selectedStatusLabel, mock.status]); + } catch (e) { + } finally { + inFlight = false; + } + }; + fetchPerturb(); + const id = setInterval(fetchPerturb, 15000); + return () => { mounted = false; clearInterval(id); }; + }, [getPerturbationJours]); - // compute and position the rail so it covers from first stop center to last stop center - useEffect(() => { - function updateRail(){ - const rail = railRef.current; - const list = stopsListRef.current; - const right = rightRef.current; - if(!rail || !list || !right) return; - const items = Array.from(list.querySelectorAll('li')); - if(items.length === 0) return; - const first = items[0]; - // find destination (last with dest class) or fallback to last item - const last = items.find(el => el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1]; + useEffect(() => { + if (!selectedHoraire || !selectedHoraire.horaire) return; + const horaire = selectedHoraire.horaire; + const stop = selectedHoraire.stop || null; + const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null; + const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null; + try { + const map = buildPerturbMap(perturbations || []); + + const todayYmd = new Date().toLocaleDateString('en-CA'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowYmd = tomorrow.toLocaleDateString('en-CA'); + const serviceDate = selectedHoraire.isTomorrow ? tomorrowYmd : todayYmd; - const rightRect = right.getBoundingClientRect(); - const firstRect = first.getBoundingClientRect(); - const lastRect = last.getBoundingClientRect(); + const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate); + const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_arrivee || horaire.time || horaire.heure); + const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_arrivee || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure) : null)); + let updatedTime = null; + if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') { + updatedTime = formatTimeFromHM(originalBase + status.delayMinutes); + } - const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top; - const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top; + // Guard to prevent infinite render loop: only set selectedHoraire if status or time actually changed + const statusChanged = !horaire._status || horaire._status.label !== status.label || horaire._status.delayMinutes !== status.delayMinutes; + const timeChanged = selectedHoraire._displayTime !== displayTimeOriginal || selectedHoraire._updatedTime !== updatedTime; - // add small padding so rail extends a bit beyond centers - const pad = 9; - const top = Math.max(0, firstCenterY - pad); - const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2); + if (statusChanged || timeChanged) { + setSelectedHoraire(prev => ({ + ...(prev||{}), + horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, + _displayTime: displayTimeOriginal, + _updatedTime: updatedTime + })); + } + } catch (e) {} + }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]); - // compute horizontal position so dots are centered next to the text - const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text'); - const textRect = firstText ? firstText.getBoundingClientRect() : firstRect; - const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px - const railWidth = 35; - const left = centerX - railWidth/2; - - // apply styles for the rail rectangle - rail.style.top = `${Math.round(top)}px`; - rail.style.height = `${Math.round(height)}px`; - rail.style.left = `${Math.round(left)}px`; - rail.style.width = `${railWidth}px`; - // remove background-image reliance and render individual dots as DOM children - // clear existing dots - while (rail.firstChild) rail.removeChild(rail.firstChild); - - // create a dot element for each item and position it exactly at the item's vertical center - const railWidthComputed = parseInt(rail.style.width, 8) || railWidth; - items.forEach(it => { - const r = it.getBoundingClientRect(); - const centerY = r.top + r.height/2 - (rightRect.top + top); - const dot = document.createElement('span'); - dot.className = 'aff-quais__rail-dot'; - dot.style.position = 'absolute'; - dot.style.left = '50%'; - dot.style.transform = 'translate(-50%, -50%)'; - dot.style.top = `${Math.round(centerY)}px`; - // set dot diameter to rail width - dot.style.width = `${railWidthComputed}px`; - dot.style.height = `${railWidthComputed}px`; - dot.style.borderRadius = '50%'; - dot.style.background = '#ffe600'; - dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset'; - rail.appendChild(dot); - }); - } - - updateRail(); - window.addEventListener('resize', updateRail); - const obs = new MutationObserver(updateRail); - const listNode = stopsListRef.current; - if(listNode) obs.observe(listNode, { childList:true, subtree:true }); - return ()=>{ - window.removeEventListener('resize', updateRail); - if(obs) obs.disconnect(); - }; - }, [selectedHoraire]); - // Observer changement des stops via selectedHoraire - // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent) - - const displayTimeOrStatus = () => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status; - const statusObj = horaire && horaire._status ? horaire._status : null; - // prefer precomputed _displayTime if available - const time = selectedHoraire && selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time); - - // build canonical status label when we have computed status object - let canonicalStatus = null; - if (statusObj) { - if (typeof statusObj.delayMinutes === 'number' && !Number.isNaN(statusObj.delayMinutes)) { - canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`; - } else if (statusObj.label && String(statusObj.label).toLowerCase().includes('supprim')) { - canonicalStatus = 'supprimé'; - } else if (statusObj.label) { - canonicalStatus = String(statusObj.label); + useEffect(() => { + let mounted = true; + async function loadNextForQuai() { + setLoadingHoraire(true); + setHoraireError(null); + setSelectedHoraire(null); + if (!supabase) { + setHoraireError('Supabase non initialisé'); + setLoadingHoraire(false); + return; + } + try { + let fetchedType = null; + try { + if (gareIdParam) { + const { data: g } = await supabase.from('gares').select('type').eq('gare_id', gareIdParam).single(); + if (g) fetchedType = g.type; + } else if (gareDisplayParam) { + const { data: gdata } = await supabase.from('gares').select('type,nom').ilike('nom', `%${gareDisplayParam}%`); + 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(gareDisplayParam || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + return n1 === n2; + }); + fetchedType = exactMatch ? exactMatch.type : gdata[0].type; } + } + } catch (e) {} + if (mounted) setStationType(fetchedType); + + const { data: all, error } = await supabase.from('horaires').select('id, *'); + if (error) { + if (mounted) setHoraireError(error.message || String(error)); + setLoadingHoraire(false); + return; + } + if (!mounted) return; + if (!Array.isArray(all) || all.length === 0) { + setLoadingHoraire(false); + return; } - const s = (canonicalStatus || statusStrFromField || '').toLowerCase(); - const isAlert = s.includes('retard') || s.includes('supprim'); - const label = canonicalStatus || statusStrFromField || ''; + const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null; + const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null; - // Logique de seuil d'affichage du quai - const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); - const sched = selectedHoraire && selectedHoraire.depHM ? selectedHoraire.depHM : null; - const threshold = thresholdForStationType(stationType); - const showPlatform = (sched !== null) ? (nowHM >= (sched - threshold)) : true; + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + const nowHM = now.getHours()*60 + now.getMinutes(); - if (!showPlatform) { - return ( - - --h-- - - ); + const candidates = []; + + for (const h of all) { + if (!h) continue; + + const circulatesToday = circulatesOn(h, now); + const circulatesTomorrow = circulatesOn(h, tomorrow); + if (!circulatesToday && !circulatesTomorrow) continue; + + const stops = getStops(h); + + let maxIdx = null; + if (Array.isArray(stops) && stops.length) { + for (const st of stops) { + 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); + } + } + } + + let stopMatch = null; + if (normalizedTarget) { + const departNameNorm = normalizeName(h.gare_depart_nom || h.gare_depart || h.origin_name || ''); + if (departNameNorm && (departNameNorm === normalizedTarget || departNameNorm.includes(normalizedTarget) || normalizedTarget.includes(departNameNorm))) { + // Target is the origin of this train, which means it doesn't arrive here, skip. + continue; + } else { + const destNameNorm = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || ''); + if (destNameNorm && (destNameNorm === normalizedTarget || destNameNorm.includes(normalizedTarget) || normalizedTarget.includes(destNameNorm))) { + let finalStop = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee }; + if (Array.isArray(stops) && stops.length) { + for (const s of stops) { + const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || ''); + const nm = normalizeName(rawName); + if (!nm) continue; + if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { + if (typeof s === 'object') { + if (!s.heure_arrivee && finalStop.heure_arrivee) s.heure_arrivee = finalStop.heure_arrivee; + finalStop = s; + } else { + finalStop.gare_nom = s; + } + break; + } + } + } + stopMatch = finalStop; + } + } + } + + if (!stopMatch && gareIdParam) { + const gid = String(gareIdParam); + const arriveeId = h.gare_arrivee_id || h.gare_id || h.destination_id || null; + if (arriveeId != null && String(arriveeId) === gid) { + stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_id: arriveeId, gare_nom: h.gare_arrivee_nom || h.gare_arrivee }; + } + if (!stopMatch && Array.isArray(stops) && stops.length) { + for (const s of stops) { + const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; + if (sid != null && String(sid) === gid) { stopMatch = s; break; } + } + } + } + + if (!stopMatch && normalizedTarget && Array.isArray(stops) && stops.length) { + for (const s of stops) { + const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.stopName || s.station || s.station_name || ''); + const nm = normalizeName(rawName); + if (!nm) continue; + if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { + const isOrigin = Boolean((typeof s === 'object') && (s.isFirst || s.is_first || s.isOrigin || (typeof s.index !== 'undefined' && Number(s.index) === 0))); + if (isOrigin) continue; + stopMatch = s; + break; + } + } + } + if (!stopMatch) continue; + + const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; + const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,''); + + if (targetQuai) { + if (!normPlatform) continue; + if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue; + } + + const stopIsObject = stopMatch && typeof stopMatch === 'object'; + const hasArrival = stopIsObject && ( + typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.horaire_time !== 'undefined' + ); + const hasDepartureOnly = stopIsObject && !hasArrival && ( + typeof stopMatch.heure_depart !== 'undefined' || typeof stopMatch.departure_time !== 'undefined' || typeof stopMatch.departure !== 'undefined' + ); + if (stopIsObject && hasDepartureOnly) continue; + + const rawTime = (hasArrival ? (stopMatch.heure_arrivee || stopMatch.time || stopMatch.heure || stopMatch.arrival_time || stopMatch.arrival) : (h.heure_arrivee || h.time || h.heure || h.arrival_time || h.arrival)); + const depHM = parseTimeHM(rawTime); + if (depHM == null) continue; + + let minutesUntil = depHM - nowHM; + if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; + + candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime, circulatesToday, circulatesTomorrow }); } - // Always render both time and status inside a fixed-size wrapper to avoid layout shift - return ( - - {time} - {label} - - ); + if (!candidates.length) { + setCandidatesCount(0); + setLoadingHoraire(false); + setSelectedHoraire(null); + return; + } + + const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); + const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM <= nowHM || !c.circulatesToday)).sort((a,b) => a.depHM - b.depHM); + + let chosen = null; + if (todayCandidates.length > 0) { + chosen = { ...todayCandidates[0], isTomorrow: false }; + } else if (tomorrowCandidates.length > 0) { + chosen = { ...tomorrowCandidates[0], isTomorrow: true }; + } + + if (mounted) { + setCandidatesCount(candidates.length); + setSelectedHoraire(chosen || null); + } + } catch (err) { + console.error('Erreur supabase quai', err); + if (mounted) setHoraireError(err.message || String(err)); + } finally { + if (mounted) setLoadingHoraire(false); + } } + loadNextForQuai(); + return () => { mounted = false; }; + }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName, circulatesOn]); + + // Rail is handled purely in CSS for better performance and alignment + const showPlatform = (() => { + const sched = selectedHoraire ? selectedHoraire.depHM : null; + if (sched === null || !stationType) return true; + const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); + const threshold = thresholdForStationType(stationType); + return nowHM >= (sched - threshold); + })(); + + if (!loadingHoraire && !selectedHoraire) { return ( -
    -
    - {/* logo (depuis le type de train) */} - {(() => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; - return {trainType; - })()} - -
    départ
    - {/* affichage des états de chargement / erreur pour debug */} - {horaireError ?
    {horaireError}
    : null} - {loadingHoraire ?
    Chargement...
    : null} - {debugEnabled ? ( -
    -
    Candidates: {candidatesCount}
    - {selectedHoraire ?
    {JSON.stringify(selectedHoraire, null, 2)}
    : null} -
    - ) : null} - {/* logo du type en haut à droite */} -
    - {(() => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const status = horaire ? (horaire.status || (horaire._status && horaire._status.label) || mock.status) : mock.status; - const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time; - const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim'); - if (isAlert) return displayTimeOrStatus(); - return ( - <> - {time} - {status} - - ); - })()} -
    -
    - {(() => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const stop = selectedHoraire && selectedHoraire.stop ? selectedHoraire.stop : null; - // For arrivals: show the origin of the train (gare_depart_*) as the main station label - const destinationBig = horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || '') : mock.destinationBig; - const rawDestinationStation = stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation); - return ( - <> -
    {destinationBig}
    - - ); - })()} -
    -
    {(() => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; - const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; - return (<>{trainType}  {trainNumber}); - })()}
    -
    -
    -
    - {/* barre verticale de points (élément explicite pour fiabilité) */} -
    -
      - {(() => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - // utiliser la helper getStops pour supporter plusieurs formats - let stops = horaire ? getStops(horaire) : []; - if (!Array.isArray(stops) || stops.length === 0) { - // fallback parsing si la colonne est une chaîne JSON - if (horaire && typeof horaire.stops === 'string') { - try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } - } - } - if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; - - // construire une liste propre en extrayant un label pour chaque stop - const labeled = stops.map((stop, idx) => { - let label = ''; - if (typeof stop === 'string') label = stop; - else if (stop && typeof stop === 'object') { - // vérifier plusieurs clés possibles - label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || ''; - // si l'objet possède une clé 'stop' qui est elle-même un objet - if (!label && stop.stop && typeof stop.stop === 'object') { - label = stop.stop.name || stop.stop.stop_name || ''; - } - } - label = String(label || '').trim(); - return { label, original: stop, idx }; - }).filter(it => it.label); - - if (labeled.length === 0) return null; - - // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop) - let matchedIndex = -1; - try { - if (selectedHoraire && selectedHoraire.stop) { - const target = selectedHoraire.stop; - const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || '')); - const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null; - for (let i = 0; i < stops.length; i++) { - const s = stops[i]; - // compare by id first if available - if (targetId != null) { - const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null; - if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; } - } - const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || ''); - if (name && targetName && name === targetName) { matchedIndex = i; break; } - } - } - } catch (e) { /* ignore */ } - - // For arrivals: show the stops served BEFORE the display station (i.e. previous stops) - // If we have a matchedIndex, keep only items with idx < matchedIndex. - // Display stops in the reverse direction relative to the route: show the farthest previous stop first - let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx < matchedIndex) : true); - // sort ascending so the oldest (farthest) previous stop appears first -> reverse of travel direction - subsequent = subsequent.sort((a,b) => a.idx - b.idx); - // Remove consecutive duplicate labels (case-insensitive) to avoid repeated identical station names - if (subsequent.length > 1) { - const collapsed = []; - for (const it of subsequent) { - const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null; - const cur = it.label ? String(it.label).trim().toLowerCase() : ''; - if (cur && prev && prev === cur) { - // skip duplicate consecutive entry - continue; - } - collapsed.push(it); - } - subsequent = collapsed; - } - // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue - if (subsequent.length === 0) return null; - - return subsequent.map((item, mapIndex) => { - const isLast = item.idx === (stops.length - 1); - return ( -
    • - - {item.label} -
    • - ); - }); - })()} -
    -
    - {/* footer band same as classic departures with clock on the right */} -
    -
    -
    -
    -
    -
    +
    +
    Aucune Arrivée Prévue
    +
    +
    +
    + +
    +
    ); -} + } + return ( +
    + {/* LEFT PANEL: Lilac/Light Grey Background */} +
    + {/* SNCF Logo */} + SNCF +
    + {(() => { + const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; + const statusObj = horaire && horaire._status ? horaire._status : null; + const isSuppressed = statusObj && statusObj.label === 'supprimé'; + const isDelayed = statusObj && typeof statusObj.delayMinutes === 'number'; + const delayMinutes = statusObj ? statusObj.delayMinutes : null; + + let timeText = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_arrivee || horaire.time || horaire.heure) : mock.time; + let statusText = horaire ? (horaire.status || (statusObj && statusObj.label) || mock.status) : mock.status; + + if (showPlatform) { + if (isSuppressed) { + if (!alternateCancelPhase) { + timeText = 'supprimé'; + statusText = ''; + } else { + statusText = 'supprimé'; + } + } else if (isDelayed) { + if (!alternateDelayPhase) { + timeText = `retardé +${delayMinutes} min`; + statusText = ''; + } else { + statusText = `retardé +${delayMinutes} min`; + } + } + } else { + timeText = '--h--'; + statusText = ''; + } + + const isTimeSuppressed = isSuppressed && !alternateCancelPhase; + const isTimeDelayed = isDelayed && !alternateDelayPhase; + + const isLongText = timeText.length > 5; + let timeClass = ''; + if (isLongText) timeClass += ' aff-quais-arrivees-redesign__time--long-text'; + if (isTimeSuppressed) timeClass += ' aff-quais-arrivees-redesign__time--suppressed'; + if (isTimeDelayed) timeClass += ' aff-quais-arrivees-redesign__time--delayed'; + + let statusClass = ''; + if (isSuppressed) statusClass += ' aff-quais-arrivees-redesign__status--suppressed'; + if (isDelayed) statusClass += ' aff-quais-arrivees-redesign__status--delayed'; + + return ( + <> + {timeText} + {statusText && {String(statusText).toLowerCase()}} + + ); + })()} +
    + +
    + {(() => { + const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; + return showPlatform ? (horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || horaire.origin || '') : mock.destinationBig) : '--'; + })()} +
    + +
    + {(() => { + const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; + const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; + const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; + return showPlatform ? ( + <> + {trainType} + {trainNumber} + + ) : ''; + })()} +
    + + {/* Accessibility Icons */} +
    + + + + + + + + + + + +
    + + {/* Watermark */} +
    arrivée
    +
    + + {/* RIGHT PANEL: Green Background with Provenance Stops */} +
    + {showPlatform &&
    gares de provenance :
    } +
      + {(() => { + if (!showPlatform) return null; + const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; + let stops = horaire ? getStops(horaire) : []; + if (!Array.isArray(stops) || stops.length === 0) { + if (horaire && typeof horaire.stops === 'string') { + try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } + } + } + if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; + + const labeled = stops.map((stop, idx) => { + let label = ''; + if (typeof stop === 'string') label = stop; + else if (stop && typeof stop === 'object') { + label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || ''; + if (!label && stop.stop && typeof stop.stop === 'object') { + label = stop.stop.name || stop.stop.stop_name || ''; + } + } + label = String(label || '').trim(); + return { label, original: stop, idx }; + }).filter(it => it.label); + + if (labeled.length === 0) return null; + + let matchedIndex = -1; + try { + if (selectedHoraire && selectedHoraire.stop) { + const target = selectedHoraire.stop; + const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || '')); + const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null; + for (let i = 0; i < stops.length; i++) { + const s = stops[i]; + if (targetId != null) { + const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null; + if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; } + } + const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || ''); + if (name && targetName && name === targetName) { matchedIndex = i; break; } + } + } + } catch (e) { /* ignore */ } + + let previousStops = labeled.filter(it => (matchedIndex >= 0) ? (it.idx <= matchedIndex) : true); + previousStops = previousStops.sort((a,b) => a.idx - b.idx); + if (previousStops.length > 1) { + const collapsed = []; + for (const it of previousStops) { + const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null; + const cur = it.label ? String(it.label).trim().toLowerCase() : ''; + if (cur && prev && prev === cur) { + continue; + } + collapsed.push(it); + } + previousStops = collapsed; + } + if (previousStops.length === 0) return null; + + return previousStops.map((item, mapIndex) => { + const stop = item.original || {}; + const stopId = stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId || null; + const stopStatus = computeStatusForHoraireAtStop(horaire, stopId, item.label, perturbMap, serviceDate); + const isStopSuppressed = stopStatus && stopStatus.label === 'supprimé'; + const isStopDelayed = stopStatus && typeof stopStatus.delayMinutes === 'number'; + const stopDelayMin = stopStatus ? stopStatus.delayMinutes : null; + + let stopTextClass = ''; + if (isStopSuppressed) stopTextClass += ' aff-quais-arrivees-redesign__stop-text--suppressed'; + if (isStopDelayed) stopTextClass += ' aff-quais-arrivees-redesign__stop-text--delayed'; + + return ( +
    • + + + {item.label} + {isStopSuppressed && (non desservi)} + {isStopDelayed && (+{stopDelayMin} min)} + +
    • + ); + }); + })()} +
    +
    + + {/* BOTTOM FOOTER: Full Width */} +
    +
    + ain. +
    +
    + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.css b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.css index 846a653..427f7b2 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.css +++ b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.css @@ -1,7 +1,3 @@ -/* Partie gauche strictement comme l'image */ -.achemine-font-face { - /* helper placeholder (no output) */ -} @font-face { font-family: 'Achemine'; src: url('/public/fonts/achemine_regular.ttf') format('truetype'); @@ -16,473 +12,333 @@ font-style: normal; font-display: swap; } -.aff-quais__left--strict { - width: 100%; - max-width: 0; - min-width: 700px; - background: #fff; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - /* Remonter l'ensemble du contenu vers le bas pour laisser la place au logo en haut */ - padding: 140px 32px 24px 32px; - box-sizing: border-box; - position: relative; - /* leave space at bottom for the footer (height 86px) */ - height: calc(100vh - 86px); - overflow: hidden; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - box-shadow: none; -} -.aff-quais__left--strict, -.aff-quais__left--strict * { - font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; -} -/* Apply blue color specifically for departures container only (avoid being overridden by arrivées stylesheet) - Use :not(.aff-quais__container--arrivees) to scope to departures */ -.aff-quais__container--strict:not(.aff-quais__container--arrivees) .aff-quais__left--strict, -.aff-quais__container--strict:not(.aff-quais__container--arrivees) .aff-quais__left--strict * { - color: #0d4a87; -} - -/* Strong override for departures container: ensure destination title isn't black/green due to other styles */ -.aff-quais__container--depart .aff-quais__left--strict, -.aff-quais__container--depart .aff-quais__left--strict * { - color: #0d4a87 !important; -} -.aff-quais__type-logo--strict{ - position: absolute; - top: 18px; - /* positionné à gauche dans la zone blanche (à droite du logo opérateur) */ - /* tout à gauche de la zone blanche */ - left: 32px; - width: 120px; - height: auto; - object-fit: contain; - z-index: 5; - pointer-events: none; -} -.aff-quais__sncf-logo--strict { - width: 86px; - height: auto; - margin-bottom: 18px; - margin-left: 0; - margin-top: 0; - position: absolute; - top: 18px; - /* placé à droite du logo du type */ - left: 140px; - z-index: 4; -} -.aff-quais__watermark { - position: absolute; - /* move watermark further to the right */ - right: -180px; - top: 70%; - transform: translateY(-50%) rotate(-90deg); - /* police agrandie de 200% */ - font-size: 200px; - color: rgba(23,55,113,0.07); - font-weight: 800; - letter-spacing: 6px; - pointer-events: none; - user-select: none; - z-index: 0; -} -.aff-quais__row--time-status { - display: flex; - align-items: flex-end; - gap: 18px; - margin-bottom: 18px; - z-index: 1; -} -.aff-quais__time--strict { - color: #0d4a87; - font-weight: 800; - /* police agrandie de 200% */ - font-size: 112px; - line-height: 1; - letter-spacing: 0.5px; - margin-right: 12px; -} - -/* Wrapper that contains both the big time and the smaller status when alternating. - Keeps the layout width/height stable so alternating does not reflow the surrounding text. */ -.aff-quais__time-status-wrapper { - position: relative; - display: inline-block; /* width is determined by the time element in normal flow */ - height: 112px; /* match .aff-quais__time font-size so status can be aligned at bottom */ - vertical-align: bottom; - overflow: visible; -} - -.aff-quais__time-status-wrapper .aff-quais__time--strict { - position: relative; /* keep in flow to define wrapper width */ - display: block; - top: 0; - left: 0; - transition: opacity 180ms linear; -} - -.aff-quais__time-status-wrapper .aff-quais__time-status--strict { - position: absolute; /* overlay on the same horizontal position */ - left: 0; - right: 0; - bottom: 0; /* align to bottom similar to previous align-self:flex-end */ - transition: opacity 180ms linear; -} - -.aff-quais__time-status-wrapper .hidden { opacity: 0; pointer-events: none; } -.aff-quais__time-status-wrapper .visible { opacity: 1; } -.aff-quais__time-status--strict { - color: #0d4a87; - /* police agrandie de 200% */ - font-size: 44px; - font-weight: 400; - opacity: 1; - align-self: flex-end; - margin-bottom: 14px; -} -.aff-quais__destination--strict { - margin-top: 10px; - margin-bottom: 0; - color: #0d4a87; - z-index: 1; -} -.aff-quais__destination-line1--strict { - /* police agrandie de 200% */ - font-size: 76px; - font-weight: 800; - line-height: 1.1; -} -.aff-quais__destination-line2--strict { - /* police agrandie de 200% */ - font-size: 76px; - font-weight: 800; - line-height: 1.1; -} -.aff-quais__train-id--strict { - margin-top: 12px; - color: #0d4a87; - font-weight: 700; - /* police agrandie de 200% */ - font-size: 44px; - letter-spacing: 0.5px; - z-index: 1; -} -.aff-quais__train-number--strict { - color: #0d4a87; - font-weight: 400; - /* police agrandie de 200% */ - font-size: 44px; -} -.aff-quais__quai{ - margin-top: 8px; - color:#0d4a87; - font-weight:800; - background: rgba(23,55,113,0.08); - padding:6px 10px; - border-radius:6px; - display:inline-block; -} - -/* central divider */ -.aff-quais__divider{ - width:18px; - background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(255, 255, 255, 0.18)); - display:flex; - justify-content:center; - align-items:flex-start; - padding-top:36px; -} -.aff-quais__divider:before{ - content:''; - width:6px; - background: transparent; - box-shadow: 0 18px 0 0 #f0e34f, 0 54px 0 0 #f0e34f, 0 90px 0 0 #f0e34f, 0 126px 0 0 #f0e34f, 0 162px 0 0 #f0e34f, 0 198px 0 0 #f0e34f; - display:block; - border-radius:4px; -} - -/* Right panel with stops */ -.aff-quais__right{ - flex:1; - background: linear-gradient(180deg,#205aa1 0%, #144b85 100%); - display:flex; - align-items:center; - box-sizing:border-box; - padding: 24px 36px; -} -.aff-quais__stops-box{ - width:100%; - max-width:820px; - margin-left:24px; -} -.aff-quais__stops-list{ - list-style:none; - margin:0; - padding:0; - width:100%; -} -.aff-quais__stop{ - display:flex; - align-items:center; - gap:18px; - padding: 10px 16px; - color:#fff; - font-size:30px; - line-height:1.1; -} -.aff-quais__bullet{ - width:14px; - height:14px; - background: #bbd258; /* yellow bullet */ - border-radius:50%; - display:inline-block; - box-shadow: 0 0 0 4px rgba(240,227,79,0.06) inset; -} -.aff-quais__stop--active{ - background: rgba(155,123,224,0.15); - border-radius:6px; -} -.aff-quais__stop--active .aff-quais__bullet{ - background:#9b7be0; /* purple for final */ -} -.aff-quais__stop-text{ - flex:1; - padding-left:6px; -} - -/* --- STRICT STYLES FOR RIGHT PANEL --- */ - -.aff-quais__right--strict { - flex: 1; - background: #205aa1; /* fond bleu uniforme */ - display: flex; - align-items: flex-start; - justify-content: flex-start; - padding: 48px 0 48px 0; - min-width: 0; - /* leave space for footer */ - height: calc(100vh - 86px); - box-sizing: border-box; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - box-shadow: none; - position: relative; /* anchor for the stops rail */ -} - -.aff-quais__stops-list--strict { - list-style: none; +body, html { margin: 0; - /* variables pour contrôler la colonne de pastilles */ - --rail-gap: 72px; /* espacement vertical entre pastilles (ajuster si nécessaire) */ - --rail-offset: 24px; /* décalage vertical initial pour aligner la première pastille */ - --rail-left: 28px; /* position X du rail inside right panel */ - --rail-width: 12px; /* largeur du rail/pastille */ - --ul-padding: 56px; /* padding-left actuel de la liste */ - /* laisser un espace modéré à gauche pour la colonne de points */ - padding: 0 0 0 var(--ul-padding); - width: 100%; - max-width: 600px; - position: relative; + padding: 0; + background-color: #0b376d; + overflow: hidden; } -/* élément rail explicite (utilisé pour afficher la colonne de pastilles) */ -.aff-quais__stops-rail{ - position: absolute; - /* position relative to the right panel container */ - left: calc(var(--rail-left) - 8px); - top: 0; - bottom: 0; - /* make rail clearly visible and wide enough */ - width: 30px; - z-index: 2; - pointer-events: none; - /* base color: same as destination rectangle */ - background-color: #b6d2f7; - overflow: visible; -} - -.aff-quais__rail-dot{ - display: block; - position: absolute; - left: 50%; - transform: translate(-50%, -50%); - /* size is set dynamically from JS to match rail width */ - width: auto; - height: auto; - border-radius: 50%; - background: #b9d25a; - box-shadow: 0 0 0 4px rgba(255,230,102,0.06) inset; -} - -.aff-quais__stop--strict { - display: flex; - align-items: center; - /* agrandi de 150% */ - font-size: calc(2.1rem * 1.5); - color: #fff; - font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; - font-weight: 400; - /* espacement augmenté entre les noms des gares */ - margin-bottom: 18px; - line-height: 1.18; - padding: 0 0 0 0; - position: relative; /* each li is positioned so its ::before aligns with the rail */ -} - -.aff-quais__stop-bullet--strict { - /* bullets individuels masqués : on utilise une barre verticale */ - display: none; -} - -/* pastille positionnée pour chaque item, alignée sur la colonne (rail) */ -.aff-quais__stop--strict::before{ - /* disable per-item pseudo pastille — rail handles the dots */ - display: none; -} - -/* barre verticale de points jaunes pour la liste des gares */ -/* removed ::before — using explicit .aff-quais__stops-rail element instead */ - -.aff-quais__stop-text--strict{ /* ensure text doesn't overlap the rail */ - padding-left: 6px; -} - -.aff-quais__status--alert{ - color: #ffe600; - font-weight: 800; -} - -.aff-quais__stop-text--strict { - display: inline-block; - font-size: inherit; - font-weight: inherit; - color: inherit; -} - -.aff-quais__stop--strict-dest { - font-weight: 800; - background: #b6d2f7; - color: #205aa1; - border-radius: 6px; - padding: 2px 32px 2px 0; - margin-left: -45px; - width: auto; - - padding-left: 45px; -} - -/* Ensure the highlighted destination stop text is always white for readability */ -.aff-quais__stop--strict-dest .aff-quais__stop-text--strict { - color: #ffffff !important; -} - -.aff-quais__container--strict { +.aff-quais-departs-redesign__container { display: flex; flex-direction: row; width: 100vw; height: 100vh; - min-height: 0; - min-width: 0; - /* Palette locale pour l'afficheur départs : évite la palette arrivées importée ailleurs */ - --blue: #205aa1; - --blue-dark: #144b85; - --accent-yellow: #e1c14c; - --white: #ffffff; - --watermark-blue: rgba(23,55,113,0.07); - background: var(--blue); - box-sizing: border-box; - overflow: hidden; - /* make container positioned so the absolute footer can be placed relative to it */ position: relative; + font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; + background-color: #0b376d; + color: #fff; + overflow: hidden; } -.aff-quais__separator--strict { - width: 14px; - min-width: 14px; - max-width: 14px; - background: #205aa1; - /* align separator with the panels which leave room for footer */ - height: calc(100vh - 86px); - display: block; +.aff-quais-departs-redesign__empty { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.aff-quais-departs-redesign__empty-text { + font-size: 6rem; + font-weight: 700; + color: #ffffff; + text-align: center; +} + +/* ========================================================= + LEFT PANEL + ========================================================= */ +.aff-quais-departs-redesign__left { + position: absolute; + top: 0; + left: 0; + width: 35%; + bottom: 80px; + background-color: #ffffff; + box-sizing: border-box; + padding: 30px 40px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.aff-quais-departs-redesign__sncf-logo { + width: 90px; + height: auto; + margin-bottom: 25px; z-index: 2; } -@media (max-width: 900px) { - .aff-quais__right--strict { - padding: 18px 0 18px 0; - } - /* moins d'espace en haut sur petits écrans */ - .aff-quais__left--strict { padding-top: 80px; } - .aff-quais__stops-list--strict { - padding-left: 12px; - } - .aff-quais__stop--strict { - font-size: 1.1rem; - } - .aff-quais__stop-bullet--strict { - width: 8px; - height: 8px; - margin-right: 8px; - } - .aff-quais__stop--strict-dest { - padding: 1px 6px 1px 0; - border-radius: 4px; - } +.aff-quais-departs-redesign__time-status { + display: flex; + align-items: baseline; + gap: 15px; + margin-bottom: 5px; + z-index: 2; } -/* --- END STRICT STYLES FOR RIGHT PANEL --- */ - -.aff-quais__footer{ - position:absolute; - left:0; - right:0; - bottom:0; - height:86px; - pointer-events:none; -} -.aff-quais__footer-inner{ - display:flex; - justify-content:space-between; - align-items:center; - height:100%; - padding: 0 28px; - background: linear-gradient(to bottom, rgba(0,0,0,0.15), rgba(0,0,0,0.25)); -} -.aff-quais__footer-left{ - color:#fff; - font-weight:700; - font-size:20px; -} -/* CLOCK BOX - unified */ -.aff-quais__clock-box{ - background: rgba(23,55,113,0.9); - color:#fff; - padding: 6px 12px; - border-radius: 6px; - border: 4px solid #ffffff; -} -.aff-quais__clock-main{ - font-size:18px; - font-weight:800; +.aff-quais-departs-redesign__time { + color: #0b376d; + font-weight: 700; + font-size: 7.5rem; + line-height: 1; } -/* position the clock bottom-right inside a small white rounded box like on the image */ -.aff-quais__footer-right{ - position:relative; -} -.aff-quais__clock{ - position:relative; -} -@media (max-width: 900px){ - .aff-quais__left{width:42%;} - .aff-quais__destination-line1{font-size:36px} - .aff-quais__destination-line2{font-size:28px} - .aff-quais__time{font-size:48px} +.aff-quais-departs-redesign__status { + color: #0b376d; + font-weight: 700; + font-size: 2.4rem; + line-height: 1; +} + +.aff-quais-departs-redesign__destination { + color: #0b376d; + font-weight: 700; + font-size: 5.5rem; /* Slightly smaller to fit 2 lines if needed */ + line-height: 1.1; + margin-bottom: 5px; + z-index: 2; + white-space: normal; /* Allow wrapping */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-height: 13rem; /* Prevent overflowing panel */ +} + +.aff-quais-departs-redesign__train-info { + display: flex; + align-items: center; + gap: 10px; + z-index: 2; +} + +.aff-quais-departs-redesign__train-type { + color: #0b376d; + font-weight: 700; + font-size: 2.2rem; + letter-spacing: 0.5px; +} + +.aff-quais-departs-redesign__train-number { + color: #929bb0; + font-weight: 400; + font-size: 2.2rem; + letter-spacing: 0.5px; +} + +.aff-quais-departs-redesign__icons { + position: absolute; + bottom: 20px; + left: 40px; + display: flex; + align-items: center; + z-index: 2; +} + +.aff-quais-departs-redesign__watermark { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%) rotate(-90deg); + transform-origin: right center; + font-size: 17rem; /* Massive size */ + color: #e5e8ed; + font-weight: 700; + z-index: 1; + pointer-events: none; + user-select: none; + letter-spacing: 1.5rem; + line-height: 1; + white-space: nowrap; +} + +/* ========================================================= + RIGHT PANEL + ========================================================= */ +.aff-quais-departs-redesign__right { + position: absolute; + top: 0; + right: 0; + width: 65%; + bottom: 80px; + background-color: #0b376d; + box-sizing: border-box; + padding: 30px 0 30px 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.aff-quais-departs-redesign__stops-list { + list-style: none; + margin: 0; + padding: 0 0 0 60px; /* Space from left edge */ + width: 100%; + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + gap: 8px; /* Closer spacing between stops */ +} + +.aff-quais-departs-redesign__stop { + display: flex; + align-items: center; + font-size: 3.4rem; + font-weight: 400; + color: #ffffff; + position: relative; + min-height: 42px; + padding-left: 50px; /* Space for rail and bullet */ +} + +/* Bullet styling */ +.aff-quais-departs-redesign__stop-bullet { + position: absolute; + left: 0; /* Centered on 24px rail: (24-16)/2 = 4 */ + margin-left: 4px; + top: 50%; + transform: translateY(-50%); + width: 16px; /* Slightly larger bullet */ + height: 16px; + background-color: #fbec22; + border-radius: 50%; + z-index: 4; +} + +.aff-quais-departs-redesign__stop-text { + position: relative; + z-index: 4; + white-space: nowrap; + letter-spacing: 0.5px; + padding-left: 10px; /* Space from bullet */ +} + +/* The vertical rail segments drawn behind each bullet */ +.aff-quais-departs-redesign__stop::before { + content: ''; + position: absolute; + left: 0; + width: 24px; /* Much wider rail */ + background-color: #5c538f; + top: -10px; + bottom: -10px; + z-index: 1; +} + +/* Rail extends all the way to the top */ +.aff-quais-departs-redesign__stop:first-child::before { + top: -1000px; +} + +/* Last stop has no rail continuing below */ +.aff-quais-departs-redesign__stop:last-child::before { + display: none; +} + +/* Highlighted Destination Stop */ +.aff-quais-departs-redesign__stop--dest { + font-weight: 700; +} + +.aff-quais-departs-redesign__stop--dest .aff-quais-departs-redesign__stop-text { + background-color: #5c538f; + padding: 4px 20px 4px 60px; /* Increased left padding to cover bullet */ + margin-left: -60px; /* Pull back to exactly align with left:0 */ + border-radius: 4px; + position: relative; + z-index: 3; /* Behind the bullet (z-index 4) */ + display: inline-block; +} + +/* ========================================================= + FOOTER + ========================================================= */ +.aff-quais-departs-redesign__footer { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 80px; + background-color: #0b376d; + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + padding: 0 40px; + z-index: 10; +} + +.aff-quais-departs-redesign__footer-left { + font-size: 3rem; + font-weight: 700; + color: #ffffff; + font-style: italic; + font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; + letter-spacing: 0.5px; +} + +.aff-quais-departs-redesign__footer-right { + display: flex; + align-items: center; +} + +.aff-quais-departs-redesign__clock { + border: 2px solid #ffffff; + padding: 4px 14px; + font-size: 3rem; + font-weight: 700; + color: #ffffff; + display: flex; + align-items: center; + gap: 10px; + font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif; +} + +.aff-quais-departs-redesign__clock-s { + color: #f5a623; +} + +.aff-quais-departs-redesign__time--long-text { + font-size: 3.5rem !important; + line-height: 1.2 !important; +} + +.aff-quais-departs-redesign__time--suppressed, +.aff-quais-departs-redesign__status--suppressed { + color: #ff4c5b !important; +} + +.aff-quais-departs-redesign__time--delayed, +.aff-quais-departs-redesign__status--delayed { + color: #ff9e43 !important; +} + +/* Disrupted Stop styling */ +.aff-quais-departs-redesign__stop-text--suppressed { + text-decoration: line-through; + color: #ff4c5b; + opacity: 0.6; +} + +.aff-quais-departs-redesign__stop-text--delayed { + color: #ff9e43; +} + +.aff-quais-departs-redesign__stop-status-badge { + font-size: 2.2rem; + font-weight: bold; + margin-left: 10px; +} + +.aff-quais-departs-redesign__stop-status-badge--suppressed { + color: #ff4c5b; +} + +.aff-quais-departs-redesign__stop-status-badge--delayed { + color: #ff9e43; } diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js index a2f8a83..214b762 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js @@ -1,70 +1,6 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesDepartsQuais.css'; import supabase from '../../../../lib/supabase'; - -// Hook pour récupérer la map des logos depuis /type/data.json -function useTypeLogos(){ - const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' }); - useEffect(() => { - let mounted = true; - - const load = async () => { - const candidates = ['/type/quais/data-color.json', '/type/data.json']; - for(const url of candidates){ - try{ - const res = await fetch(url); - if(!res.ok) continue; - const data = await res.json(); - if(!mounted) return; - const logos = {}; - if(Array.isArray(data.logos)){ - data.logos.forEach(item => { - // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path - let file = item.file; - if(!file && item.path){ - const parts = String(item.path).split('/'); - file = parts[parts.length-1]; - } - if(!file) return; - const basename = file.replace(/\.[^/.]+$/, ''); - const semantic = basename.replace(/^logo-/, ''); - const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-'); - // Prefer color logos in the 'quais' subfolder if data came from the quais file - if(url.includes('/quais/')){ - logos[key] = `/type/quais/${file}`; - } else { - logos[key] = `/type/${file}`; - } - }); - } - logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png'; - setMap(logos); - return; - }catch(e){ - // try next candidate (ignore error and continue loop) - } - } - // no candidate succeeded — keep fallback - }; - - load(); - return () => { mounted = false; }; - }, []); - return map; -} - -function getTypeLogo(type, TYPE_LOGOS_MAP){ - const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' }; - if(!type) return map.default; - const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-'); - if(map[norm]) return map[norm]; - const keys = Object.keys(map).filter(k => k !== 'default'); - for(const k of keys){ - if(k.includes(norm) || norm.includes(k)) return map[k]; - } - return map.default; -} - function Clock() { const [now, setNow] = useState(new Date()); useEffect(() => { @@ -75,20 +11,15 @@ function Clock() { const mm = String(now.getMinutes()).padStart(2,'0'); const ss = String(now.getSeconds()).padStart(2,'0'); return ( -
    -
    {hh}:{mm}:{ss}
    +
    + {hh} + {mm} + {ss}
    ); } -export default function ClassiquesDepartsQuais(){ - const typeLogos = useTypeLogos(); - - const rightRef = useRef(null); - const stopsListRef = useRef(null); - const railRef = useRef(null); - - // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée) +export default function ClassiquesDepartsQuais() { const mock = { time: '16h44', status: "à l'heure", @@ -101,26 +32,37 @@ export default function ClassiquesDepartsQuais(){ ], }; - // état pour l'horaire sélectionné const [selectedHoraire, setSelectedHoraire] = useState(null); const [loadingHoraire, setLoadingHoraire] = useState(false); - const [horaireError, setHoraireError] = useState(null); - const [candidatesCount, setCandidatesCount] = useState(0); - // type de la gare (récupéré depuis la table gares) + const [, setHoraireError] = useState(null); + const [, setCandidatesCount] = useState(0); const [stationType, setStationType] = useState(null); - // Heure actuelle mise à jour chaque seconde pour la logique de seuil const [currentTime, setCurrentTime] = useState(new Date()); + const [alternateDelayPhase, setAlternateDelayPhase] = useState(true); + const [alternateCancelPhase, setAlternateCancelPhase] = useState(true); useEffect(() => { const id = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(id); }, []); - // perturbations/travaux (récupérées depuis Supabase) + useEffect(() => { + const interval = setInterval(() => { + setAlternateDelayPhase(prev => !prev); + }, 5000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const interval = setInterval(() => { + setAlternateCancelPhase(prev => !prev); + }, 5000); + return () => clearInterval(interval); + }, []); + const [perturbations, setPerturbations] = useState([]); const [, setTravaux] = useState([]); - // helpers locaux (simples) — similaires à ceux du composant principal const weekdayNameForColumn = useCallback((d) => { const i = d.getDay(); switch(i) { @@ -141,20 +83,19 @@ export default function ClassiquesDepartsQuais(){ const day = d.getDate(); const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const holidays = [ - `${y}-01-01`, // Jour de l'an - `${y}-05-01`, // Fête du Travail - `${y}-05-08`, // Victoire 1945 - `${y}-07-14`, // Fête nationale - `${y}-08-15`, // Assomption - `${y}-11-01`, // Toussaint - `${y}-11-11`, // Armistice 1918 - `${y}-12-25`, // Noël + `${y}-01-01`, + `${y}-05-01`, + `${y}-05-08`, + `${y}-07-14`, + `${y}-08-15`, + `${y}-11-01`, + `${y}-11-11`, + `${y}-12-25`, ]; - // Dates variables pour 2026 if (y === 2026) { - holidays.push('2026-04-06'); // Lundi de Pâques - holidays.push('2026-05-14'); // Ascension - holidays.push('2026-05-25'); // Lundi de Pentecôte + holidays.push('2026-04-06'); + holidays.push('2026-05-14'); + holidays.push('2026-05-25'); } return holidays.includes(s); }, []); @@ -215,8 +156,6 @@ export default function ClassiquesDepartsQuais(){ return String(v).slice(0,5).replace(':','h'); }, []); - // ...existing code... - const formatTimeFromHM = useCallback((totalMinutes) => { if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return ''; const m = ((totalMinutes % 1440) + 1440) % 1440; @@ -225,24 +164,21 @@ export default function ClassiquesDepartsQuais(){ return `${hh}h${mm}`; }, []); - // retourne le threshold (en minutes) pour afficher le quai selon le type de gare const thresholdForStationType = useCallback((type) => { if (!type) return 0; const t = String(type).trim().toLowerCase(); - if (t === 'interurbaine' || t.includes('interurb')) return 360; // 6 heures - if (t === 'ville' || t.includes('ville')) return 30; // 30 minutes + if (t === 'interurbaine' || t.includes('interurb')) return 360; + if (t === 'ville' || t.includes('ville')) return 30; return 0; }, []); - // lecture des query params pour gare & quai const paramsForThis = new URLSearchParams(window.location.search || ''); const gareParam = paramsForThis.get('gare'); const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId'); const quaiParam = paramsForThis.get('quai'); - const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true'); + const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null; - // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs) const getStops = (h) => { if (!h) return []; if (Array.isArray(h.stops) && h.stops.length) return h.stops; @@ -255,13 +191,12 @@ export default function ClassiquesDepartsQuais(){ 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 */ } + try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) {} } } return []; }; - // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) --- const safeParseArrets = (p) => { const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations); if (!raw) return []; @@ -386,7 +321,7 @@ export default function ClassiquesDepartsQuais(){ for (const p of found) { try { if (horaireYmd) { - const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut); + 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; @@ -436,7 +371,15 @@ export default function ClassiquesDepartsQuais(){ return { label: "à l'heure", detail: '', delayMinutes: null }; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName, parsePerturbArretsObject]); - // Fetch perturbations & travaux from Supabase periodically + const perturbMap = React.useMemo(() => buildPerturbMap(perturbations || []), [perturbations, buildPerturbMap]); + const serviceDate = React.useMemo(() => { + const todayYmd = new Date().toLocaleDateString('en-CA'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowYmd = tomorrow.toLocaleDateString('en-CA'); + return selectedHoraire && selectedHoraire.isTomorrow ? tomorrowYmd : todayYmd; + }, [selectedHoraire]); + useEffect(() => { let mounted = true; let inFlight = false; @@ -452,19 +395,32 @@ export default function ClassiquesDepartsQuais(){ ]); if (!mounted) return; if (pError || tError) { - // ignore quietly — keep previous data } else { - // Filter perturbations to those applicable today const todayYmd = new Date().toLocaleDateString('en-CA'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowYmd = tomorrow.toLocaleDateString('en-CA'); + 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 || p.start_date || p.date_debut); + 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 !== todayYmd) return false; - if (db && df && !(todayYmd >= db && todayYmd <= df)) return false; - try { const joursSet = getPerturbationJours(p); if (joursSet && !joursSet.has(todayYmd)) return false; } catch (e) {} + + const appliesToToday = (!pDate || pDate === todayYmd) && (!db || !df || (todayYmd >= db && todayYmd <= df)); + const appliesToTomorrow = (!pDate || pDate === tomorrowYmd) && (!db || !df || (tomorrowYmd >= db && tomorrowYmd <= df)); + + if (!appliesToToday && !appliesToTomorrow) return false; + + try { + const joursSet = getPerturbationJours(p); + if (joursSet) { + const hasToday = joursSet.has(todayYmd); + const hasTomorrow = joursSet.has(tomorrowYmd); + if (!hasToday && !hasTomorrow) return false; + } + } catch (e) {} return true; } catch (e) { return true; } }); @@ -472,7 +428,6 @@ export default function ClassiquesDepartsQuais(){ setTravaux(travauxData || []); } } catch (e) { - // ignore } finally { inFlight = false; } @@ -482,7 +437,6 @@ export default function ClassiquesDepartsQuais(){ return () => { mounted = false; clearInterval(id); }; }, [getPerturbationJours]); - // Annotate selectedHoraire with computed status when perturbations change useEffect(() => { if (!selectedHoraire || !selectedHoraire.horaire) return; const horaire = selectedHoraire.horaire; @@ -491,20 +445,36 @@ export default function ClassiquesDepartsQuais(){ const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null; try { const map = buildPerturbMap(perturbations || []); - const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null); - // keep original scheduled time for display (_displayTime) + + const todayYmd = new Date().toLocaleDateString('en-CA'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowYmd = tomorrow.toLocaleDateString('en-CA'); + const serviceDate = selectedHoraire.isTomorrow ? tomorrowYmd : todayYmd; + + const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate); const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure); const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null)); - // compute updated time (delayed) but store separately — do not override the original displayed time let updatedTime = null; if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') { updatedTime = formatTimeFromHM(originalBase + status.delayMinutes); } - setSelectedHoraire(prev => ({ ...(prev||{}), horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime })); - } catch (e) { /* ignore */ } + + // Guard to prevent infinite render loop: only set selectedHoraire if status or time actually changed + const statusChanged = !horaire._status || horaire._status.label !== status.label || horaire._status.delayMinutes !== status.delayMinutes; + const timeChanged = selectedHoraire._displayTime !== displayTimeOriginal || selectedHoraire._updatedTime !== updatedTime; + + if (statusChanged || timeChanged) { + setSelectedHoraire(prev => ({ + ...(prev||{}), + horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status }, + _displayTime: displayTimeOriginal, + _updatedTime: updatedTime + })); + } + } catch (e) {} }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]); - // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai) useEffect(() => { let mounted = true; async function loadNextForQuai() { @@ -517,17 +487,23 @@ export default function ClassiquesDepartsQuais(){ return; } try { - // Récupérer le type de la gare let fetchedType = null; try { if (gareIdParam) { const { data: g } = await supabase.from('gares').select('type').eq('gare_id', gareIdParam).single(); if (g) fetchedType = g.type; } else if (gareDisplayParam) { - const { data: g } = await supabase.from('gares').select('type').ilike('nom', `%${gareDisplayParam}%`).limit(1).single(); - if (g) fetchedType = g.type; + const { data: gdata } = await supabase.from('gares').select('type,nom').ilike('nom', `%${gareDisplayParam}%`); + 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(gareDisplayParam || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + return n1 === n2; + }); + fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + } } - } catch (e) { /* ignore */ } + } catch (e) {} if (mounted) setStationType(fetchedType); const { data: all, error } = await supabase.from('horaires').select('id, *'); @@ -555,15 +531,12 @@ export default function ClassiquesDepartsQuais(){ for (const h of all) { if (!h) continue; - // Filtrage par jour de circulation const circulatesToday = circulatesOn(h, now); const circulatesTomorrow = circulatesOn(h, tomorrow); if (!circulatesToday && !circulatesTomorrow) continue; - // build stops list (support multiple field names) const stops = getStops(h); - // compute max index for terminus detection if stops contain index let maxIdx = null; if (Array.isArray(stops) && stops.length) { for (const st of stops) { @@ -574,34 +547,24 @@ export default function ClassiquesDepartsQuais(){ } } - // find matching stop (by name or by gare_id) let stopMatch = null; - // check horaire-level depart name first if (normalizedTarget) { - // exclude horaires where the target gare is the final destination (terminus) const destNameNorm = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || ''); if (destNameNorm && (destNameNorm === normalizedTarget || destNameNorm.includes(normalizedTarget) || normalizedTarget.includes(destNameNorm))) { - // gare ciblée est la gare d'arrivée finale -> ne pas considérer cet horaire - // skip to next horaire } else { const departName = normalizeName(h.gare_depart_nom || h.gare_depart || h.origin_name || ''); if (departName && (departName === normalizedTarget || departName.includes(normalizedTarget) || normalizedTarget.includes(departName))) { - // créer un stopMatch initial à partir du niveau horaire let initialStop = { heure_depart: h.heure_depart || h.time || h.heure, gare_nom: h.gare_depart_nom || h.gare_depart }; - // si la liste stops contient un objet correspondant à la gare de départ, préférer cet objet if (Array.isArray(stops) && stops.length) { for (const s of stops) { const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { - // prefer the stops entry (it may contain platform/quai information) - // but preserve a horaire-level time if the stop entry lacks heure_depart if (typeof s === 'object') { if (!s.heure_depart && initialStop.heure_depart) s.heure_depart = initialStop.heure_depart; initialStop = s; } else { - // s is string name — keep initialStop but set gare_nom initialStop.gare_nom = s; } break; @@ -613,15 +576,12 @@ export default function ClassiquesDepartsQuais(){ } } - // if gare_id param present, try to match by id as well if (!stopMatch && gareIdParam) { const gid = String(gareIdParam); - // check horaire-level id const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; if (departId != null && String(departId) === gid) { stopMatch = { heure_depart: h.heure_depart || h.time || h.heure, gare_id: departId, gare_nom: h.gare_depart_nom || h.gare_depart }; } - // check stops list ids if (!stopMatch && Array.isArray(stops) && stops.length) { for (const s of stops) { const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; @@ -630,35 +590,29 @@ export default function ClassiquesDepartsQuais(){ } } - // if still no match, try match by normalized name inside stops if (!stopMatch && normalizedTarget && Array.isArray(stops) && stops.length) { for (const s of stops) { - // support both string entries and object entries const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.stopName || s.station || s.station_name || ''); const nm = normalizeName(rawName); if (!nm) continue; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { - // detect if this stop is a terminus (isLast / is_last / index == maxIdx) const isTerminus = Boolean((typeof s === 'object') && (s.isLast || s.is_last || s.isTerminus || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx))); - if (isTerminus) continue; // ne pas considérer les terminus + if (isTerminus) continue; stopMatch = s; break; } } } - if (!stopMatch) continue; // this horaire does not serve the target gare (or match failed) + if (!stopMatch) continue; - // determine platform/quai for this stop/horaire const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,''); - // if targetQuai is set, ensure it matches (loose compare: contains or equals) if (targetQuai) { - if (!normPlatform) continue; // no platform info -> skip + if (!normPlatform) continue; if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue; } - // Ne garder que les horaires qui ont un départ au stop (on ne veut pas afficher les arrivées) const stopIsObject = stopMatch && typeof stopMatch === 'object'; const hasDeparture = stopIsObject && ( typeof stopMatch.heure_depart !== 'undefined' || typeof stopMatch.departure_time !== 'undefined' || typeof stopMatch.departure !== 'undefined' || typeof stopMatch.time !== 'undefined' || typeof stopMatch.departureTime !== 'undefined' || typeof stopMatch.horaire_time !== 'undefined' @@ -666,15 +620,12 @@ export default function ClassiquesDepartsQuais(){ const hasArrivalOnly = stopIsObject && !hasDeparture && ( typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' ); - // si le stop correspond mais n'a que l'heure d'arrivée (pas de départ), ignorer cet horaire — on veut uniquement des départs if (stopIsObject && hasArrivalOnly) continue; - // pick time from stopMatch (si on a une heure de départ) ou fallback au niveau horaire const rawTime = (hasDeparture ? (stopMatch.heure_depart || stopMatch.time || stopMatch.heure || stopMatch.departure_time || stopMatch.departure || stopMatch.departureTime || stopMatch.horaire_time) : (h.heure_depart || h.time || h.heure || h.departure_time || h.departure || h.departureTime || h.horaire_time)); const depHM = parseTimeHM(rawTime); if (depHM == null) continue; - // compute minutes until departure (allow next-day wrap) let minutesUntil = depHM - nowHM; if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; @@ -688,13 +639,15 @@ export default function ClassiquesDepartsQuais(){ return; } - // split into today (circulatesToday AND depHM > nowHM) and tomorrow (circulatesTomorrow AND (depHM <= nowHM OR not circulatesToday)) const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM <= nowHM || !c.circulatesToday)).sort((a,b) => a.depHM - b.depHM); let chosen = null; - if (todayCandidates.length > 0) chosen = todayCandidates[0]; - else if (tomorrowCandidates.length > 0) chosen = tomorrowCandidates[0]; + if (todayCandidates.length > 0) { + chosen = { ...todayCandidates[0], isTomorrow: false }; + } else if (tomorrowCandidates.length > 0) { + chosen = { ...tomorrowCandidates[0], isTomorrow: true }; + } if (mounted) { setCandidatesCount(candidates.length); @@ -712,232 +665,149 @@ export default function ClassiquesDepartsQuais(){ return () => { mounted = false; }; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName, circulatesOn]); - // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present) - const [showTime, setShowTime] = useState(true); - // derive a simple status label string to use in the dependency array (avoids complex expressions) - const _selectedStatusLabel = (() => { - const horaireObj = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const computedLabel = horaireObj && horaireObj._status && horaireObj._status.label ? String(horaireObj._status.label) : null; - const rawStatus = horaireObj && horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status && horaireObj.status.label ? String(horaireObj.status.label) : null)) : null; - return String(computedLabel || rawStatus || mock.status || '').toLowerCase(); + // Rail is handled purely in CSS for better performance and alignment + const showPlatform = (() => { + const sched = selectedHoraire ? selectedHoraire.depHM : null; + if (sched === null || !stationType) return true; + const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); + const threshold = thresholdForStationType(stationType); + return nowHM >= (sched - threshold); })(); - useEffect(()=>{ - let id = null; - if (/retard|supprim/.test(_selectedStatusLabel)) { - // ensure we start by showing the time - setShowTime(true); - id = setInterval(() => setShowTime(prev => !prev), 3000); - } else { - setShowTime(true); - } - return () => { if (id) clearInterval(id); }; - }, [_selectedStatusLabel, mock.status]); - - // compute and position the rail so it covers from first stop center to last stop center - useEffect(() => { - function updateRail(){ - const rail = railRef.current; - const list = stopsListRef.current; - const right = rightRef.current; - if(!rail || !list || !right) return; - const items = Array.from(list.querySelectorAll('li')); - if(items.length === 0) return; - const first = items[0]; - // find destination (last with dest class) or fallback to last item - const last = items.find(el => el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1]; - - const rightRect = right.getBoundingClientRect(); - const firstRect = first.getBoundingClientRect(); - const lastRect = last.getBoundingClientRect(); - - const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top; - const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top; - - // add small padding so rail extends a bit beyond centers - const pad = 9; - const top = Math.max(0, firstCenterY - pad); - const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2); - - // compute horizontal position so dots are centered next to the text - const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text'); - const textRect = firstText ? firstText.getBoundingClientRect() : firstRect; - const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px - const railWidth = 35; - const left = centerX - railWidth/2; - - // apply styles for the rail rectangle - rail.style.top = `${Math.round(top)}px`; - rail.style.height = `${Math.round(height)}px`; - rail.style.left = `${Math.round(left)}px`; - rail.style.width = `${railWidth}px`; - // remove background-image reliance and render individual dots as DOM children - // clear existing dots - while (rail.firstChild) rail.removeChild(rail.firstChild); - - // create a dot element for each item and position it exactly at the item's vertical center - const railWidthComputed = parseInt(rail.style.width, 8) || railWidth; - items.forEach(it => { - const r = it.getBoundingClientRect(); - const centerY = r.top + r.height/2 - (rightRect.top + top); - const dot = document.createElement('span'); - dot.className = 'aff-quais__rail-dot'; - dot.style.position = 'absolute'; - dot.style.left = '50%'; - dot.style.transform = 'translate(-50%, -50%)'; - dot.style.top = `${Math.round(centerY)}px`; - // set dot diameter to rail width - dot.style.width = `${railWidthComputed}px`; - dot.style.height = `${railWidthComputed}px`; - dot.style.borderRadius = '50%'; - dot.style.background = '#ffe600'; - dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset'; - rail.appendChild(dot); - }); - } - - updateRail(); - window.addEventListener('resize', updateRail); - const obs = new MutationObserver(updateRail); - const listNode = stopsListRef.current; - if(listNode) obs.observe(listNode, { childList:true, subtree:true }); - return ()=>{ - window.removeEventListener('resize', updateRail); - if(obs) obs.disconnect(); - }; - }, [selectedHoraire]); - // Observer changement des stops via selectedHoraire - // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent) - - const displayTimeOrStatus = () => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status; - const statusObj = horaire && horaire._status ? horaire._status : null; - // prefer precomputed _displayTime if available - const time = selectedHoraire && selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time); - - // build canonical status label when we have computed status object - let canonicalStatus = null; - if (statusObj) { - if (typeof statusObj.delayMinutes === 'number' && !Number.isNaN(statusObj.delayMinutes)) { - canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`; - } else if (statusObj.label && String(statusObj.label).toLowerCase().includes('supprim')) { - canonicalStatus = 'supprimé'; - } else if (statusObj.label) { - canonicalStatus = String(statusObj.label); - } - } - - const s = (canonicalStatus || statusStrFromField || '').toLowerCase(); - const isAlert = s.includes('retard') || s.includes('supprim'); - const label = canonicalStatus || statusStrFromField || ''; - - // Logique de seuil d'affichage du quai - const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); - const sched = selectedHoraire && selectedHoraire.depHM ? selectedHoraire.depHM : null; - const threshold = thresholdForStationType(stationType); - const showPlatform = (sched !== null) ? (nowHM >= (sched - threshold)) : true; - - if (!showPlatform) { - return ( - - --h-- - - ); - } - - // Always render both time and status inside a fixed-size wrapper to avoid layout shift + if (!loadingHoraire && !selectedHoraire) { return ( - - {time} - {label} - +
    +
    Aucun Départ Prévu
    +
    +
    +
    + +
    +
    +
    ); } - return ( -
    -
    - {/* logo (depuis le type de train) */} - {(() => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; - return {trainType; - })()} + return ( +
    + {/* LEFT PANEL: White Background */} +
    + {/* SNCF Logo */} + SNCF -
    départ
    - {/* affichage des états de chargement / erreur pour debug */} - {horaireError ?
    {horaireError}
    : null} - {loadingHoraire ?
    Chargement...
    : null} - {debugEnabled ? ( -
    -
    Candidates: {candidatesCount}
    - {selectedHoraire ?
    {JSON.stringify(selectedHoraire, null, 2)}
    : null} -
    - ) : null} - {/* logo du type en haut à droite */} -
    +
    {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const status = horaire ? (horaire.status || (horaire._status && horaire._status.label) || mock.status) : mock.status; - const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time; - const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim'); - if (isAlert) return displayTimeOrStatus(); + const statusObj = horaire && horaire._status ? horaire._status : null; + const isSuppressed = statusObj && statusObj.label === 'supprimé'; + const isDelayed = statusObj && typeof statusObj.delayMinutes === 'number'; + const delayMinutes = statusObj ? statusObj.delayMinutes : null; + + let timeText = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time; + let statusText = horaire ? (horaire.status || (statusObj && statusObj.label) || mock.status) : mock.status; + + if (showPlatform) { + if (isSuppressed) { + if (!alternateCancelPhase) { + timeText = 'supprimé'; + statusText = ''; + } else { + statusText = 'supprimé'; + } + } else if (isDelayed) { + if (!alternateDelayPhase) { + timeText = `retardé +${delayMinutes} min`; + statusText = ''; + } else { + statusText = `retardé +${delayMinutes} min`; + } + } + } else { + timeText = '--h--'; + statusText = ''; + } + + const isTimeSuppressed = isSuppressed && !alternateCancelPhase; + const isTimeDelayed = isDelayed && !alternateDelayPhase; + + const isLongText = timeText.length > 5; + let timeClass = ''; + if (isLongText) timeClass += ' aff-quais-departs-redesign__time--long-text'; + if (isTimeSuppressed) timeClass += ' aff-quais-departs-redesign__time--suppressed'; + if (isTimeDelayed) timeClass += ' aff-quais-departs-redesign__time--delayed'; + + let statusClass = ''; + if (isSuppressed) statusClass += ' aff-quais-departs-redesign__status--suppressed'; + if (isDelayed) statusClass += ' aff-quais-departs-redesign__status--delayed'; + return ( <> - {time} - {status} + {timeText} + {statusText && {String(statusText).toLowerCase()}} ); })()}
    -
    + +
    {(() => { const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const stop = selectedHoraire && selectedHoraire.stop ? selectedHoraire.stop : null; - // destinationBig is the final destination of the train (gare_arrivee_*). The second line should be the station name of the stop on the route (if present) — avoid duplicating final destination. - const destinationBig = horaire ? (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '') : mock.destinationBig; - const rawDestExpr = (stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation)); - return ( - <> -
    {destinationBig}
    - - - ); + return showPlatform ? (horaire ? (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '') : mock.destinationBig) : '--'; })()}
    -
    {(() => { - const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; - const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; - return (<>{trainType}  {trainNumber}); - })()}
    + +
    + {(() => { + const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; + const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType; + const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber; + return showPlatform ? ( + <> + {trainType} + {trainNumber} + + ) : ''; + })()} +
    + + {/* Accessibility Icons */} +
    + + + + + + + + + + + +
    + + {/* Watermark */} +
    départ
    -
    -
    - {/* barre verticale de points (élément explicite pour fiabilité) */} -
    -
      + + {/* RIGHT PANEL: Blue Background with Stops */} +
      +
        {(() => { + if (!showPlatform) return null; const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null; - // utiliser la helper getStops pour supporter plusieurs formats let stops = horaire ? getStops(horaire) : []; if (!Array.isArray(stops) || stops.length === 0) { - // fallback parsing si la colonne est une chaîne JSON if (horaire && typeof horaire.stops === 'string') { try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } } } if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; - // construire une liste propre en extrayant un label pour chaque stop const labeled = stops.map((stop, idx) => { let label = ''; if (typeof stop === 'string') label = stop; else if (stop && typeof stop === 'object') { - // vérifier plusieurs clés possibles label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties && (stop.properties.name || stop.properties.stop_name)) || ''; - // si l'objet possède une clé 'stop' qui est elle-même un objet if (!label && stop.stop && typeof stop.stop === 'object') { label = stop.stop.name || stop.stop.stop_name || ''; } @@ -948,7 +818,6 @@ export default function ClassiquesDepartsQuais(){ if (labeled.length === 0) return null; - // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop) let matchedIndex = -1; try { if (selectedHoraire && selectedHoraire.stop) { @@ -957,7 +826,6 @@ export default function ClassiquesDepartsQuais(){ const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null; for (let i = 0; i < stops.length; i++) { const s = stops[i]; - // compare by id first if available if (targetId != null) { const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null; if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; } @@ -968,11 +836,8 @@ export default function ClassiquesDepartsQuais(){ } } catch (e) { /* ignore */ } - // filtrer pour ne garder que les prochains arrêts après la gare renseignée - let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx > matchedIndex) : true); - // trier par idx pour être sûr + let subsequent = labeled.filter(it => (matchedIndex >= 0) ? (it.idx >= matchedIndex) : true); subsequent = subsequent.sort((a,b) => a.idx - b.idx); - // Remove consecutive duplicate labels (case-insensitive) if (subsequent.length > 1) { const collapsed = []; for (const it of subsequent) { @@ -985,28 +850,45 @@ export default function ClassiquesDepartsQuais(){ } subsequent = collapsed; } - // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue if (subsequent.length === 0) return null; return subsequent.map((item, mapIndex) => { - const isLast = item.idx === (stops.length - 1); + const isLast = mapIndex === subsequent.length - 1; + const stop = item.original || {}; + const stopId = stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId || null; + const stopStatus = computeStatusForHoraireAtStop(horaire, stopId, item.label, perturbMap, serviceDate); + const isStopSuppressed = stopStatus && stopStatus.label === 'supprimé'; + const isStopDelayed = stopStatus && typeof stopStatus.delayMinutes === 'number'; + const stopDelayMin = stopStatus ? stopStatus.delayMinutes : null; + + let stopTextClass = ''; + if (isStopSuppressed) stopTextClass += ' aff-quais-departs-redesign__stop-text--suppressed'; + if (isStopDelayed) stopTextClass += ' aff-quais-departs-redesign__stop-text--delayed'; + return ( -
      • - - {item.label} +
      • + + + {item.label} + {isStopSuppressed && (non desservi)} + {isStopDelayed && (+{stopDelayMin} min)} +
      • ); }); })()}
      - {/* footer band same as classic departures with clock on the right */} -
      -
      -
      -
      + + {/* BOTTOM FOOTER: Full Width */} +
      +
      + ain. +
      +
      +
      ); -} +} \ No newline at end of file diff --git a/test_date.js b/test_date.js new file mode 100644 index 0000000..c9a9f3d --- /dev/null +++ b/test_date.js @@ -0,0 +1,12 @@ +const horaire_date = "2026-06-14"; +const baseDate = new Date(horaire_date); +console.log("baseDate before setHours:", baseDate.toString()); +const sched = 330; // 05:30 +baseDate.setHours(Math.floor(sched / 60), sched % 60, 0, 0); +console.log("baseDate after setHours:", baseDate.toString()); + +const currentTime = new Date("2026-06-14T04:49:15+02:00"); +console.log("currentTime:", currentTime.toString()); + +const diffMinutes = (baseDate.getTime() - currentTime.getTime()) / 60000; +console.log("diffMinutes:", diffMinutes); diff --git a/test_db.js b/test_db.js new file mode 100644 index 0000000..650d419 --- /dev/null +++ b/test_db.js @@ -0,0 +1,17 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); + +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const gareDisplay = "Ouges"; + const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; + console.log("flexNeedle:", flexNeedle); + const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').ilike('nom', flexNeedle).limit(1); + console.log("gdata:", gdata); + let fetchedType = null; + if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; + console.log("fetchedType:", fetchedType); +} + +run(); diff --git a/test_db2.js b/test_db2.js new file mode 100644 index 0000000..e27b482 --- /dev/null +++ b/test_db2.js @@ -0,0 +1,10 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data } = await supabase.from('gares').select('id,nom,type').ilike('nom', '%Ouges%'); + console.log("Matched gares:", data); +} + +run(); diff --git a/test_db3.js b/test_db3.js new file mode 100644 index 0000000..7c89d5c --- /dev/null +++ b/test_db3.js @@ -0,0 +1,12 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data: horaires, error: hErr } = await supabase.from('horaires').select('*').limit(2); + console.log("Horaires sample:", horaires); + const { data: perturbs, error: pErr } = await supabase.from('perturbations').select('*').limit(2); + console.log("Perturbations sample:", perturbs); +} + +run(); diff --git a/test_logic.js b/test_logic.js new file mode 100644 index 0000000..340cde9 --- /dev/null +++ b/test_logic.js @@ -0,0 +1,85 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +const normalizeName = (s) => { + if (!s) return ''; + try { + const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' '); + } catch (e) { + return String(s).toLowerCase().trim(); + } +}; + +const parseTimeHM = (v) => { + if (!v) return null; + const m = String(v).match(/(\d{1,2}):(\d{2})/); + if (m) return parseInt(m[1], 10)*60 + parseInt(m[2], 10); + return null; +}; + +async function run() { + const gareDisplay = "Ouges"; + const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; + const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', flexNeedle).limit(1); + const stationType = gdata && gdata.length ? gdata[0].type.toLowerCase() : null; + console.log("stationType:", stationType); + + const thresholdForStationType = (typeGare) => { + const tG = String(typeGare || '').trim().toLowerCase(); + if (tG === 'interurbaine' || tG.includes('interurb')) return 360; + if (tG === 'ville' || tG.includes('ville')) return 30; + return 30; + }; + const threshold = thresholdForStationType(stationType); + console.log("threshold:", threshold); + + const { data: allData } = await supabase.from('horaires').select('*').limit(200); + const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges")); + const h = horaires[0]; + + const stops = h.gares_desservies; + const targetStationName = gareDisplay; + + const stopForStation = (stops || []).find(s => { + const nm = s.gare_nom || s.nom || s.name || s.stop_name || ''; + const ns = normalizeName(nm || ''); + const target = normalizeName(targetStationName || ''); + return (targetStationName && (ns === target || ns.includes(target) || target.includes(ns))); + }) || null; + + h._displayPlatform = (stopForStation && stopForStation.quai_reel) || h.quai_reel || ''; + console.log("_displayPlatform:", h._displayPlatform); + + const platform = (h._displayPlatform || '').toString().trim(); + let showPlatform = Boolean(platform); + console.log("Boolean(platform):", showPlatform); + + if (showPlatform) { + let diffMinutes = null; + h._depHM = parseTimeHM(h.heure_depart); + const sched = h._depHM; + console.log("sched:", sched); + + const currentTime = new Date(); + if (sched !== null) { + const baseDate = new Date(); + baseDate.setHours(Math.floor(sched / 60), sched % 60, 0, 0); + const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); + if (sched < 240 && nowHM > 1200) baseDate.setDate(baseDate.getDate() + 1); + else if (sched > 1200 && nowHM < 240) baseDate.setDate(baseDate.getDate() - 1); + diffMinutes = (baseDate.getTime() - currentTime.getTime()) / 60000; + } + console.log("diffMinutes:", diffMinutes); + + if (diffMinutes !== null) { + showPlatform = (diffMinutes <= threshold); + } else { + showPlatform = false; + } + } + console.log("FINAL showPlatform:", showPlatform); +} + +run(); diff --git a/test_ouges.js b/test_ouges.js new file mode 100644 index 0000000..d3f531c --- /dev/null +++ b/test_ouges.js @@ -0,0 +1,10 @@ +const { createClient } = require('@supabase/supabase-js'); +const supabaseUrl = 'https://supabase-ferrovia.mr-patator.fr'; +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1OTUxMjAxLCJleHAiOjE5MzM2MzEyMDF9.ZFuzZSHVOMX-poU1fhWTjZ2sn7LFz9NYATTwJknlSr0'; +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function test() { + const { data: horaires, error } = await supabase.from('horaires').select('*').eq('numero_train', '891807'); + console.log(horaires[0]); +} +test(); diff --git a/test_ouges2.js b/test_ouges2.js new file mode 100644 index 0000000..5c500c4 --- /dev/null +++ b/test_ouges2.js @@ -0,0 +1,16 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function test() { + const { data: horaires } = await supabase.from('horaires').select('*').limit(100); + const matched = horaires.filter(h => JSON.stringify(h).includes("Ouges")); + const h = matched[0]; + console.log("Train:", h.numero_train); + console.log("Stops:", h.gares_desservies.map(s => ({ + gare_nom: s.gare_nom, + quai_reel: s.quai_reel, + gare_id: s.gare_id + }))); +} +test(); diff --git a/test_pert.js b/test_pert.js new file mode 100644 index 0000000..bd9cb4e --- /dev/null +++ b/test_pert.js @@ -0,0 +1,10 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data } = await supabase.from('perturbations').select('*'); + console.log("Perturbations:", data); +} + +run(); diff --git a/test_quai.js b/test_quai.js new file mode 100644 index 0000000..18b8ac6 --- /dev/null +++ b/test_quai.js @@ -0,0 +1,10 @@ +const { createClient } = require('@supabase/supabase-js'); +const supabaseUrl = 'https://supabase-ferrovia.mr-patator.fr'; +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1OTUxMjAxLCJleHAiOjE5MzM2MzEyMDF9.ZFuzZSHVOMX-poU1fhWTjZ2sn7LFz9NYATTwJknlSr0'; +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function test() { + const { data: horaires, error } = await supabase.from('horaires_departs').select('*'); + console.log('Error:', error); +} +test(); diff --git a/test_sun.js b/test_sun.js new file mode 100644 index 0000000..1802965 --- /dev/null +++ b/test_sun.js @@ -0,0 +1,10 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data } = await supabase.from('horaires').select('numero_train, heure_depart, circule_dimanche').ilike('gares_desservies', '%Ouges%'); + console.log("Trains Ouges on Sunday:", data); +} + +run(); diff --git a/test_sun2.js b/test_sun2.js new file mode 100644 index 0000000..dda6f4e --- /dev/null +++ b/test_sun2.js @@ -0,0 +1,10 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data } = await supabase.from('horaires').select('numero_train, circule_dimanche').in('numero_train', ['891801', '891813']); + console.log("Trains Ouges on Sunday:", data); +} + +run(); diff --git a/test_sun3.js b/test_sun3.js new file mode 100644 index 0000000..99fcb2b --- /dev/null +++ b/test_sun3.js @@ -0,0 +1,11 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, gares_desservies, circule_dimanche').limit(200); + const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges") && h.circule_dimanche === true); + console.log("Trains Ouges on Sunday:", horaires.length); +} + +run(); diff --git a/test_sun4.js b/test_sun4.js new file mode 100644 index 0000000..49c4f7e --- /dev/null +++ b/test_sun4.js @@ -0,0 +1,14 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, heure_depart, gares_desservies, circule_dimanche').limit(200); + const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges") && h.circule_dimanche === true); + horaires.forEach(h => { + const stop = h.gares_desservies.find(s => s.gare_nom === 'Ouges'); + console.log(`Train ${h.numero_train} departs Ouges at ${stop ? stop.heure_depart : h.heure_depart}`); + }); +} + +run(); diff --git a/test_sun5.js b/test_sun5.js new file mode 100644 index 0000000..78bcc83 --- /dev/null +++ b/test_sun5.js @@ -0,0 +1,14 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '/Users/mathisgrillot/IdeaProjects/afficheurs-ferrovia/.env' }); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); + +async function run() { + const { data: allData } = await supabase.from('horaires').select('numero_train, gare_depart_nom, gare_arrivee_nom, heure_depart, gares_desservies, circule_dimanche').limit(200); + const horaires = allData.filter(h => JSON.stringify(h).includes("Ouges") && h.circule_dimanche === true); + horaires.forEach(h => { + const stop = h.gares_desservies.find(s => s.gare_nom === 'Ouges'); + console.log(`Train ${h.numero_train} departs Ouges at ${stop ? stop.heure_depart : h.heure_depart} with quai_reel: ${stop ? stop.quai_reel : 'none'}`); + }); +} + +run();