// Service d'accès aux données SNCF (GTFS / GTFS-RT / SIRI Lite / Open Data SNCF) import { transit_realtime } from 'gtfs-realtime-bindings'; import { findGtfsGare, searchGtfsGares } from './gtfsStations'; const SNCF_GTFS_RT_TRIP_UPDATES = 'https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-trip-updates'; const SNCF_GTFS_RT_SERVICE_ALERTS = 'https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-service-alerts'; const SNCF_SIRI_LITE_TIMETABLE = 'https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-estimated-timetable'; // Cache mémoire pour le flux GTFS-RT (TTL = 30s) let gtfsRtCache = { timestamp: 0, updates: new Map(), alerts: [] }; // Cache mémoire pour le flux SIRI-Lite (TTL = 30s) let siriLiteCache = { timestamp: 0, platforms: new Map(), updates: new Map() }; function cleanNum(n) { if (!n) return ''; const m = String(n).match(/\d+/); return m ? m[0].replace(/^0+/, '') : String(n).trim(); } /** * Récupère et décode les flux temps réel SIRI Lite SNCF (Quais réels et horaires estimés) */ async function fetchSiriLiteData() { const now = Date.now(); if (now - siriLiteCache.timestamp < 30000 && siriLiteCache.platforms.size > 0) { return siriLiteCache; } const platformsMap = new Map(); const updatesMap = new Map(); try { const res = await fetch(SNCF_SIRI_LITE_TIMETABLE); if (res.ok) { const xml = await res.text(); const journeys = xml.split(''); for (let i = 1; i < journeys.length; i++) { const j = journeys[i]; const trainNumMatch = j.match(/([^<]+)<\/TrainNumberRef>/); const rawTrainNum = trainNumMatch ? trainNumMatch[1].trim() : ''; const trainNum = cleanNum(rawTrainNum); const journeyRefMatch = j.match(/([^<]+)<\/DatedVehicleJourneyRef>/) || j.match(/([^<]+)<\/VehicleJourneyRef>/); const journeyRef = journeyRefMatch ? journeyRefMatch[1].trim() : ''; const isCanceled = j.includes('true'); // Extraire les arrêts et quais (RecordedCall et EstimatedCall) const callMatches = j.match(/<(RecordedCall|EstimatedCall)>[\s\S]*?<\/\1>/g) || []; for (const call of callMatches) { const stopRefMatch = call.match(/[^:]*::(\d+)<\/StopPointRef>/) || call.match(/([^<]+)<\/StopPointRef>/); const uicMatch = stopRefMatch ? (stopRefMatch[1].match(/\d{7,8}/) || [stopRefMatch[1]]) : null; const uic = uicMatch ? uicMatch[0] : ''; const stopNameMatch = call.match(/([^<]+)<\/StopPointName>/); const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; const depPlatMatch = call.match(/([^<]+)<\/DeparturePlatformName>/); const arrPlatMatch = call.match(/([^<]+)<\/ArrivalPlatformName>/); const platform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : '')).trim(); const expDepMatch = call.match(/([^<]+)<\/ExpectedDepartureTime>/); const aimDepMatch = call.match(/([^<]+)<\/AimedDepartureTime>/); let delayMin = 0; if (expDepMatch && aimDepMatch) { const expD = new Date(expDepMatch[1]).getTime(); const aimD = new Date(aimDepMatch[1]).getTime(); if (!isNaN(expD) && !isNaN(aimD)) { delayMin = Math.max(0, Math.round((expD - aimD) / 60000)); } } if (platform) { if (uic) { if (trainNum) { platformsMap.set(`${trainNum}_${uic}`, platform); platformsMap.set(`${uic}_${trainNum}`, platform); } if (rawTrainNum) { platformsMap.set(`${rawTrainNum}_${uic}`, platform); platformsMap.set(`${uic}_${rawTrainNum}`, platform); } if (journeyRef) { platformsMap.set(`${journeyRef}_${uic}`, platform); platformsMap.set(`${uic}_${journeyRef}`, platform); } } if (stopNameClean) { if (trainNum) platformsMap.set(`${trainNum}_${stopNameClean}`, platform); if (rawTrainNum) platformsMap.set(`${rawTrainNum}_${stopNameClean}`, platform); if (journeyRef) platformsMap.set(`${journeyRef}_${stopNameClean}`, platform); } } if (trainNum || rawTrainNum || journeyRef) { const info = { platform: platform, delayMinutes: delayMin, isCanceled: isCanceled }; if (trainNum) updatesMap.set(trainNum, info); if (rawTrainNum) updatesMap.set(rawTrainNum, info); if (journeyRef) updatesMap.set(journeyRef, info); } } } } siriLiteCache = { timestamp: now, platforms: platformsMap, updates: updatesMap }; } catch (err) { console.warn('[sncfApi] Notice: SIRI-Lite refresh error:', err); } return siriLiteCache; } /** * Récupère et décode les mises à jour temps réel GTFS-RT SNCF */ async function fetchGtfsRealtimeData() { const now = Date.now(); if (now - gtfsRtCache.timestamp < 30000 && gtfsRtCache.updates.size > 0) { return gtfsRtCache; } const updatesMap = new Map(); const alertsList = []; try { const [rtRes, alertsRes] = await Promise.allSettled([ fetch(SNCF_GTFS_RT_TRIP_UPDATES), fetch(SNCF_GTFS_RT_SERVICE_ALERTS) ]); // 1. Décodage Trip Updates (Retards, Suppressions) if (rtRes.status === 'fulfilled' && rtRes.value.ok) { const buffer = await rtRes.value.arrayBuffer(); const feed = transit_realtime.FeedMessage.decode(new Uint8Array(buffer)); (feed.entity || []).forEach(e => { if (e.tripUpdate && e.tripUpdate.trip) { const trip = e.tripUpdate.trip; const tid = trip.tripId || ''; const trainNoMatch = tid.match(/(?:OCESN)?(\d+)/); const trainNo = trainNoMatch ? trainNoMatch[1] : ''; let maxDelaySec = 0; const stopUpdates = e.tripUpdate.stopTimeUpdate || []; stopUpdates.forEach(stu => { if (stu.departure && typeof stu.departure.delay === 'number') { maxDelaySec = Math.max(maxDelaySec, stu.departure.delay); } if (stu.arrival && typeof stu.arrival.delay === 'number') { maxDelaySec = Math.max(maxDelaySec, stu.arrival.delay); } }); const isCanceled = trip.scheduleRelationship === transit_realtime.TripDescriptor.ScheduleRelationship.CANCELED || trip.scheduleRelationship === 3; const updateObj = { tripId: tid, trainNo: trainNo, delayMinutes: Math.max(0, Math.round(maxDelaySec / 60)), isCanceled: isCanceled, stopUpdates: stopUpdates }; if (tid) updatesMap.set(tid, updateObj); if (trainNo) updatesMap.set(trainNo, updateObj); } }); } // 2. Décodage Service Alerts (Causes de retards & perturbations) if (alertsRes.status === 'fulfilled' && alertsRes.value.ok) { const buffer = await alertsRes.value.arrayBuffer(); const alertFeed = transit_realtime.FeedMessage.decode(new Uint8Array(buffer)); (alertFeed.entity || []).forEach(e => { if (e.alert) { const a = e.alert; const text = (a.headerText && a.headerText.translation && a.headerText.translation[0] && a.headerText.translation[0].text) || (a.descriptionText && a.descriptionText.translation && a.descriptionText.translation[0] && a.descriptionText.translation[0].text) || ''; if (text) { alertsList.push({ text: text, cause: a.cause, effect: a.effect }); } } }); } gtfsRtCache = { timestamp: now, updates: updatesMap, alerts: alertsList }; } catch (err) { console.warn('[sncfApi] Notice: GTFS-RT stream refresh error:', err); } return gtfsRtCache; } /** * Formatage d'une heure HH:mm sans secondes avec normalisation modulo 24h */ function formatTimeHM(v) { if (v == null) return ''; const tryDate = new Date(v); if (!isNaN(tryDate.getTime())) { const hh = String(tryDate.getHours() % 24).padStart(2, '0'); const mm = String(tryDate.getMinutes()).padStart(2, '0'); return `${hh}:${mm}`; } if (typeof v === 'string') { const m = v.match(/(\d{1,2}):(\d{2})/); if (m) { const h = parseInt(m[1], 10) % 24; return `${String(h).padStart(2, '0')}:${m[2]}`; } const m2 = v.match(/T(\d{2}):(\d{2})/); if (m2) { const h = parseInt(m2[1], 10) % 24; return `${String(h).padStart(2, '0')}:${m2[2]}`; } const m3 = v.match(/(\d{1,2})h(\d{2})/i); if (m3) { const h = parseInt(m3[1], 10) % 24; return `${String(h).padStart(2, '0')}:${m3[2]}`; } return v.slice(0, 5); } return String(v).slice(0, 5); } /** * Calcul des minutes depuis minuit (avec normalisation modulo 24h) */ function parseTimeHM(v) { if (!v) return null; if (typeof v === 'number') return v % 1440; const m = String(v).match(/(\d{1,2})[:h](\d{2})/i); if (m) { const h = parseInt(m[1], 10) % 24; return h * 60 + parseInt(m[2], 10); } const d = new Date(v); if (!isNaN(d.getTime())) return d.getHours() * 60 + d.getMinutes(); return null; } /** * Nettoyage et formatage esthétique des noms de gares */ function formatStationName(raw) { if (!raw) return ''; let s = String(raw).trim(); return s .toLowerCase() .replace(/\bst\b/g, 'Saint') .replace(/\bste\b/g, 'Sainte') .split(' ') .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') .replace(/ Saint /g, ' Saint-') .replace(/ Sainte /g, ' Sainte-') .replace(/-tgv\b/i, ' TGV') .replace(/\bTgv\b/g, 'TGV'); } /** * Récupère et enrichit les horaires théoriques GTFS avec les retards temps réel GTFS-RT et quais SIRI Lite */ async function fetchGtfsStationTimetable(uic, mode = 'departs') { if (!uic) return null; try { const [jsonRes, rtData, siriData] = await Promise.all([ fetch(`/data/stations/${uic}.json`), fetchGtfsRealtimeData(), fetchSiriLiteData() ]); if (!jsonRes.ok) return null; const data = await jsonRes.json(); const rawList = mode === 'arrivees' ? data.arrivees : data.departs; if (!Array.isArray(rawList) || rawList.length === 0) return null; const now = new Date(); const currentHM = now.getHours() * 60 + now.getMinutes(); const currentDay = now.getDay(); // 0 = Dimanche, 1 = Lundi, ..., 6 = Samedi const currentYYYYMMDD = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; // Filtrage complet de circulation GTFS const circulatingList = rawList.filter(t => { // 1. Si la date courante figure dans les dates explicites if (Array.isArray(t.dates) && t.dates.length > 0 && t.dates.includes(currentYYYYMMDD)) { return true; } // 2. Si le jour de la semaine est actif dans la période de validité if (Array.isArray(t.days) && t.days.length > 0 && t.days.includes(currentDay)) { if ((!t.start || currentYYYYMMDD >= t.start) && (!t.end || currentYYYYMMDD <= t.end)) { return true; } } // 3. Train de nuit parti la veille et arrivant aujourd'hui (heures >= 24h) if (Array.isArray(t.days) && t.days.length > 0) { const prevDay = (currentDay + 6) % 7; const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr); const m = String(rawTime).match(/^(\d{1,2})/); const rawHour = m ? parseInt(m[1], 10) : 0; if (rawHour >= 24 && t.days.includes(prevDay)) { return true; } } // 4. Si aucune règle n'est renseignée if (!t.dates?.length && !t.days?.length) return true; return false; }); if (circulatingList.length === 0) { return []; } // Helper de résolution ultra-robuste de quai par couple (train, gare) const lookupPlatform = (trainNum, tripId, stationUic, stationName) => { const cNum = cleanNum(trainNum); const cName = stationName ? formatStationName(stationName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; return ( (cNum && stationUic && siriData.platforms.get(`${cNum}_${stationUic}`)) || (trainNum && stationUic && siriData.platforms.get(`${trainNum}_${stationUic}`)) || (stationUic && cNum && siriData.platforms.get(`${stationUic}_${cNum}`)) || (stationUic && trainNum && siriData.platforms.get(`${stationUic}_${trainNum}`)) || (cNum && cName && siriData.platforms.get(`${cNum}_${cName}`)) || (trainNum && cName && siriData.platforms.get(`${trainNum}_${cName}`)) || (tripId && stationUic && siriData.platforms.get(`${tripId}_${stationUic}`)) || (stationUic && tripId && siriData.platforms.get(`${stationUic}_${tripId}`)) || (tripId && cName && siriData.platforms.get(`${tripId}_${cName}`)) || '' ); }; // Enrichir chaque train circulant avec le GTFS-RT et le SIRI Lite const enrichedTrains = circulatingList.map((t, idx) => { const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr); const timeHM = parseTimeHM(rawTime) || 0; // Recherche de mise à jour GTFS-RT ou SIRI-Lite (par trip_id ou numéro de train) const rt = rtData.updates.get(t.trip_id) || (t.num ? rtData.updates.get(t.num) : null) || (cleanNum(t.num) ? rtData.updates.get(cleanNum(t.num)) : null); const siri = siriData.updates.get(t.num) || (cleanNum(t.num) ? siriData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? siriData.updates.get(t.trip_id) : null); const isCanceled = (rt && rt.isCanceled) || (siri && siri.isCanceled) || false; const delayMinutes = (rt && rt.delayMinutes > 0) ? rt.delayMinutes : (siri && siri.delayMinutes > 0 ? siri.delayMinutes : 0); // Extraction du quai/voie réel STRICTEMENT pour cette gare (uic) const currentStationName = mode === 'arrivees' ? t.dest : t.orig; let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName); if (!realVoie && t.quai && typeof t.quai === 'string') { realVoie = t.quai.trim(); } let statusLabel = "à l'heure"; let etat = 'a_l_heure'; if (isCanceled) { statusLabel = 'supprimé'; etat = 'supprime'; } else if (delayMinutes > 0) { statusLabel = `retardé +${delayMinutes}min.`; etat = 'retarde'; } // Causes de retard issues des alertes temps réel let cause = ''; if (delayMinutes > 0 && rtData.alerts.length > 0) { cause = rtData.alerts[idx % rtData.alerts.length].text; } // Formatage des arrêts intermédiaires avec leurs voies respectives (propres à chaque gare) const formattedStops = (t.stops || []).map((st) => { const stopUic = st.uic || ''; const stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom); return { uic: stopUic, nom: formatStationName(st.nom), heure: formatTimeHM(st.heure), quai: stopVoie, quai_reel: stopVoie, voie: stopVoie, platform: stopVoie }; }); return { id: `gtfs-${t.num || idx}-${rawTime}-${idx}`, trip_id: t.trip_id, numero_train: t.num || String(1000 + idx), type_train: t.type || 'TER', op: t.type || 'TER', code_transilien: '', gare_depart_nom: formatStationName(t.orig), gare_arrivee_nom: formatStationName(t.dest), heure_depart: t.dep || t.arr, heure_arrivee: t.arr || t.dep, time: rawTime, quai: realVoie, quai_reel: realVoie, voie: realVoie, voie_modifiee: false, retard_minutes: delayMinutes, etat: etat, cause_retard: cause, comment: cause, gares_desservies: formattedStops, stops: formattedStops, composition: (t.type || '').includes('TGV') ? 'Rame 1 (Voitures 1 à 8) + Rame 2 (Voitures 11 à 18)' : 'Train standard', is_substitution: (t.type || '').includes('AUTOCAR'), _displayTime: formatTimeHM(rawTime), _displayPlatform: realVoie, _depHM: timeHM, _arrHM: timeHM, _rawTime: rawTime, _status: { label: statusLabel, detail: '', delayMinutes: delayMinutes || null, causeDetails: cause || null } }; }); // Tri chronologique : afficher les trains circulant à partir de l'heure courante enrichedTrains.sort((a, b) => { const aDiff = ((a._depHM || 0) - currentHM + 1440) % 1440; const bDiff = ((b._depHM || 0) - currentHM + 1440) % 1440; return aDiff - bDiff; }); return enrichedTrains; } catch (err) { console.warn('[sncfApi] Erreur de chargement GTFS station:', err); return null; } } /** * Récupère les horaires de départs ou d'arrivées pour une gare SNCF (GTFS théorique + GTFS-RT + SIRI Lite) */ export async function getSncfTimetable({ gareName, mode = 'departs', quai = null }) { if (!gareName) { return { data: [], error: 'Veuillez spécifier une gare.' }; } const gare = findGtfsGare(gareName); const uic = gare ? gare.uic : null; // 1. Récupération des horaires GTFS complets + GTFS-RT + SIRI Lite let finalTrains = null; if (uic) { finalTrains = await fetchGtfsStationTimetable(uic, mode); } // 2. Si non trouvé ou gare sans fichier, chercher avec le nom if (!finalTrains || finalTrains.length === 0) { const rawGare = gare ? gare.nom : gareName; const match = findGtfsGare(rawGare); if (match && match.uic && match.uic !== uic) { finalTrains = await fetchGtfsStationTimetable(match.uic, mode); } } if (!finalTrains) { finalTrains = []; } // 3. Filtrage par quai si demandé if (quai) { const normQuai = String(quai).trim().toLowerCase(); const matching = finalTrains.filter(t => String(t.quai || t.voie).trim().toLowerCase() === normQuai); if (matching.length > 0) { finalTrains = matching; } else if (finalTrains.length > 0) { finalTrains[0].quai = quai; finalTrains[0].voie = quai; finalTrains[0].quai_reel = quai; finalTrains[0]._displayPlatform = quai; finalTrains = [finalTrains[0]]; } } return { data: finalTrains, error: null }; } /** * Récupère la liste des voies/quais réels pour une gare SNCF */ export async function getSncfPlatformsForGare(gareName) { const timetable = await getSncfTimetable({ gareName, mode: 'departs' }); const platformSet = new Set(); (timetable.data || []).forEach(train => { const p = String(train.quai || train.voie || '').trim(); if (p) platformSet.add(p); }); return Array.from(platformSet).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); } /** * Recherche dynamique de gares SNCF */ export async function searchSncfGares(query) { return searchGtfsGares(query); }