// 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'; const SNCF_SIRI_LITE_SITUATION_EXCHANGE = 'https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-situation-exchange'; const NAVITIA_BASE_URL = 'https://api.sncf.com/v1'; const NAVITIA_TOKEN = (typeof process !== 'undefined' && process.env && process.env.REACT_APP_NAVITIA_TOKEN) || 'bf06df97-2ff1-4560-b74c-f127cb70764d'; // TTL de rafraîchissement des flux temps réel SNCF : 2 minutes (120 secondes) const REALTIME_CACHE_TTL = 120000; // Cache mémoire pour le flux GTFS-RT (TTL = 2 min) let gtfsRtCache = { timestamp: 0, updates: new Map(), alerts: [] }; // Cache mémoire pour le flux SIRI-Lite Estimated Timetable (TTL = 2 min) let siriLiteCache = { timestamp: 0, platforms: new Map(), updates: new Map() }; // Cache mémoire pour le flux SIRI-Lite Situation Exchange (TTL = 2 min) let situationExchangeCache = { timestamp: 0, platforms: new Map(), updates: new Map(), alerts: [] }; // Cache mémoire pour l'API OpenData SNCF (TTL = 2 min) const openDataPlatformsCache = new Map(); const MAJOR_HUBS = [ 'paris', 'lyon', 'dijon', 'marseille', 'lille', 'strasbourg', 'bordeaux', 'toulouse', 'nantes', 'rennes', 'nice', 'montpellier', 'nimes', 'metz', 'nancy', 'mulhouse', 'tours', 'le mans', 'rouen', 'clermont ferrand', 'grenoble', 'chambery', 'avignon', 'valence', 'besancon viotte', 'saint etienne', 'toulon', 'cannes', 'antibes', 'angers' ]; const MAJOR_HUB_UICS = [ '87713040', '87686006', '87271007', '87391003', '87113001', '87384008', '87547000', '87686667', '87722025', '87723197', '87751008', '87286005', '87286013', '87212027', '87581009', '87611004', '87481002', '87471003', '87756056', '87773002', '87775007', '87192039', '87141002', '87182063', '87571000', '87444000', '87411017', '87734004', '87747006', '87741009', '87765008', '87318964', '87763029', '87718007', '87726000', '87755009', '87757625', '87757674', '87484006' ]; export function isMajorHub(stationName, uic = '') { if (uic && MAJOR_HUB_UICS.includes(String(uic).replace(/\D/g, ''))) return true; if (!stationName) return false; const clean = String(stationName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim(); return MAJOR_HUBS.some(hub => clean === hub || clean.startsWith(hub + ' ') || clean.endsWith(' ' + hub)); } export function isCarNumber(num) { const d = String(num || '').replace(/\D/g, ''); return (d.length === 5 && d.startsWith('3')) || (d.length === 6 && d.startsWith('3')); } /** * Résout la voie pour une circulation : * - Aucune voie nominale/fictive inventée : seuls les quais temps réel SNCF sont affichés. * - Les services par autocar affichent 'AUTOCAR' (pictogramme). */ export function resolveNominalTrack(stationName, trainNum, uic = '') { const rawNum = String(trainNum || '').replace(/\D/g, ''); if (isCarNumber(rawNum)) return 'AUTOCAR'; return ''; } // Helper fetch avec timeout pour ne jamais bloquer l'affichage en cas de réseau lent ou de blocage async function fetchWithTimeout(url, options = {}, timeoutMs = 25000) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(id); return res; } catch (err) { clearTimeout(id); throw err; } } function cleanNum(n) { if (!n) return ''; const m = String(n).match(/\d+/); return m ? m[0].replace(/^0+/, '') : String(n).trim(); } function extractAllTrainNumbers(journeyXml) { const nums = new Set(); // 1. TrainNumberRef const tnr = journeyXml.match(/([^<]+)<\/TrainNumberRef>/); if (tnr) { const c = cleanNum(tnr[1]); if (c) nums.add(c); } // 2. VehicleJourneyName const vjn = journeyXml.match(/([^<]+)<\/VehicleJourneyName>/); if (vjn) { const c = cleanNum(vjn[1]); if (c) nums.add(c); } // 3. PublishedLineName if numeric const pln = journeyXml.match(/([^<]+)<\/PublishedLineName>/); if (pln) { const m = pln[1].match(/^\d+$/); if (m) nums.add(cleanNum(m[0])); } // 4. DatedVehicleJourneyRef / VehicleJourneyRef / FramedVehicleJourneyRef const refs = journeyXml.match(/<(?:DatedVehicleJourneyRef|VehicleJourneyRef|FramedVehicleJourneyRef)>([\s\S]*?)<\/(?:DatedVehicleJourneyRef|VehicleJourneyRef|FramedVehicleJourneyRef)>/g) || []; for (const r of refs) { const vjMatch = r.match(/FR:VehicleJourney::(\d{3,7})/i) || r.match(/VehicleJourney::(\d{3,7})/i) || r.match(/::(\d{3,7})/); if (vjMatch) { nums.add(cleanNum(vjMatch[1])); } const lrMatch = r.match(/LineRef::(\d{3,7})/i); if (lrMatch) { nums.add(cleanNum(lrMatch[1])); } } return Array.from(nums); } /** * Récupère et décode le flux SIRI Lite SNCF officiel en temps réel (circulations dans les 60 prochaines minutes) * Source : https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-estimated-timetable */ async function fetchSiriLiteData() { const now = Date.now(); if (now - siriLiteCache.timestamp < REALTIME_CACHE_TTL && siriLiteCache.platforms.size > 0) { return siriLiteCache; } const platformsMap = new Map(); const updatesMap = new Map(); try { const res = await fetchWithTimeout(SNCF_SIRI_LITE_TIMETABLE, { headers: { 'Accept': 'application/xml, text/xml, */*' } }, 25000); if (res && res.ok) { const xml = await res.text(); const journeys = xml.split(''); for (let i = 1; i < journeys.length; i++) { const j = journeys[i]; // 1. Extraction exhaustive des identifiants et numéros de train const trainNums = extractAllTrainNumbers(j); const journeyRefMatch = j.match(/([^<]+)<\/DatedVehicleJourneyRef>/) || j.match(/([^<]+)<\/VehicleJourneyRef>/) || j.match(/[\s\S]*?([^<]+)<\/DatedVehicleJourneyRef>/); const journeyRef = journeyRefMatch ? journeyRefMatch[1].trim() : ''; const isCanceledJourney = j.includes('true') || j.includes('cancelled'); // 2. Extraire tous les arrêts desservis (RecordedCall et EstimatedCall) const callMatches = j.match(/<(?:RecordedCall|EstimatedCall)>[\s\S]*?<\/(?:RecordedCall|EstimatedCall)>/g) || []; for (const call of callMatches) { // Extraction du code UIC de l'arrêt const stopRefMatch = call.match(/([^<]+)<\/StopPointRef>/); let uic = ''; let uic7 = ''; if (stopRefMatch) { const rawRef = stopRefMatch[1]; const uicDigits = rawRef.match(/\d{7,8}/); uic = uicDigits ? uicDigits[0] : rawRef.replace(/\D/g, ''); if (uic.length === 8 && uic.startsWith('87')) { uic7 = uic.slice(2); } } const stopNameMatch = call.match(/([^<]+)<\/StopPointName>/); const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; // Extraction des quais (Départ, Arrivée, Prévu ou Réel) const depPlatMatch = call.match(/<(?:DeparturePlatformName|PlannedDeparturePlatformName)>([^<]+)<\/(?:DeparturePlatformName|PlannedDeparturePlatformName)>/); const arrPlatMatch = call.match(/<(?:ArrivalPlatformName|PlannedArrivalPlatformName)>([^<]+)<\/(?:ArrivalPlatformName|PlannedArrivalPlatformName)>/); const quayMatch = call.match(/([^<]+)<\/QuayRef>/); let rawPlatform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : (quayMatch ? quayMatch[1] : ''))).trim(); const platform = rawPlatform.replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); // Extraction des heures et calcul du retard const expDepMatch = call.match(/([^<]+)<\/ExpectedDepartureTime>/); const aimDepMatch = call.match(/([^<]+)<\/AimedDepartureTime>/); const expArrMatch = call.match(/([^<]+)<\/ExpectedArrivalTime>/); const aimArrMatch = call.match(/([^<]+)<\/AimedArrivalTime>/); let delayMin = 0; const expTimeStr = (expDepMatch && expDepMatch[1]) || (expArrMatch && expArrMatch[1]); const aimTimeStr = (aimDepMatch && aimDepMatch[1]) || (aimArrMatch && aimArrMatch[1]); if (expTimeStr && aimTimeStr) { const expD = new Date(expTimeStr).getTime(); const aimD = new Date(aimTimeStr).getTime(); if (!isNaN(expD) && !isNaN(aimD)) { delayMin = Math.max(0, Math.round((expD - aimD) / 60000)); } } const isStopCancelled = isCanceledJourney || call.includes('cancelled') || call.includes('cancelled') || call.includes('true'); if (platform) { for (const tNum of trainNums) { if (uic) { platformsMap.set(`${tNum}_${uic}`, platform); platformsMap.set(`${uic}_${tNum}`, platform); } if (uic7) { platformsMap.set(`${tNum}_${uic7}`, platform); platformsMap.set(`${uic7}_${tNum}`, platform); } if (stopNameClean) { platformsMap.set(`${tNum}_${stopNameClean}`, platform); } } if (journeyRef) { if (uic) { platformsMap.set(`${journeyRef}_${uic}`, platform); platformsMap.set(`${uic}_${journeyRef}`, platform); } if (stopNameClean) { platformsMap.set(`${journeyRef}_${stopNameClean}`, platform); } } } const info = { platform: platform, delayMinutes: delayMin, isCanceled: isStopCancelled }; for (const tNum of trainNums) { if (uic) updatesMap.set(`${tNum}_${uic}`, info); updatesMap.set(tNum, info); } if (journeyRef) { if (uic) updatesMap.set(`${journeyRef}_${uic}`, info); updatesMap.set(journeyRef, info); } } } } siriLiteCache = { timestamp: now, platforms: platformsMap, updates: updatesMap }; } catch (err) { console.warn('[sncfApi] Notice: SIRI-Lite Estimated Timetable refresh error/timeout:', err); } return siriLiteCache; } /** * Récupère et décode le flux SIRI Lite Situation Exchange SNCF (Circulations à venir, perturbations, changements de voie, suppressions) * Source : https://proxy.transport.data.gouv.fr/resource/sncf-siri-lite-situation-exchange */ async function fetchSituationExchangeData() { const now = Date.now(); if (now - situationExchangeCache.timestamp < REALTIME_CACHE_TTL && situationExchangeCache.platforms.size > 0) { return situationExchangeCache; } const platformsMap = new Map(); const updatesMap = new Map(); const alertsList = []; try { const res = await fetchWithTimeout(SNCF_SIRI_LITE_SITUATION_EXCHANGE, { headers: { 'Accept': 'application/xml, text/xml, */*' } }, 12000); if (res && res.ok) { const xml = await res.text(); const situations = xml.split(''); for (let i = 1; i < situations.length; i++) { const s = situations[i]; // 1. Extraction exhaustive du train et de la mission const trainNums = extractAllTrainNumbers(s); const trainNumMatch = s.match(/([^<]+)<\/TrainNumberRef>/) || s.match(/([^<]+)<\/VehicleJourneyRef>/) || s.match(/([^<]+)<\/PublishedLineName>/); const rawTrainNum = trainNumMatch ? trainNumMatch[1].trim() : ''; const primaryTrainNum = cleanNum(rawTrainNum); if (primaryTrainNum && !trainNums.includes(primaryTrainNum)) { trainNums.push(primaryTrainNum); } // 2. Extraction de l'arrêt impacté const stopRefMatch = s.match(/([^<]+)<\/StopPointRef>/); let uic = ''; let uic7 = ''; if (stopRefMatch) { const rawRef = stopRefMatch[1]; const uicDigits = rawRef.match(/\d{7,8}/); uic = uicDigits ? uicDigits[0] : rawRef.replace(/\D/g, ''); if (uic.length === 8 && uic.startsWith('87')) { uic7 = uic.slice(2); } } const stopNameMatch = s.match(/([^<]+)<\/StopPointName>/); const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; // 3. Extraction des messages en français const summaryFrMatch = s.match(/]*xml:lang="FR"[^>]*>([^<]+)<\/Summary>/i) || s.match(/]*>([^<]+)<\/Summary>/i); const summary = summaryFrMatch ? summaryFrMatch[1].trim() : ''; const descFrMatch = s.match(/]*xml:lang="FR"[^>]*>([^<]+)<\/Description>/i) || s.match(/]*>([^<]+)<\/Description>/i); const desc = descFrMatch ? descFrMatch[1].trim() : ''; const messageText = desc || summary || ''; // 4. Détection de changement de quai / voie const platTagMatch = s.match(/<(?:DeparturePlatformName|PlannedDeparturePlatformName|ArrivalPlatformName|PlannedArrivalPlatformName|QuayRef)>([^<]+)<\//i); let platform = platTagMatch ? platTagMatch[1].replace(/^(?:voie|quai|track|platform)\s*/i, '').trim() : ''; if (!platform && messageText) { const voieTextMatch = messageText.match(/\b(?:voie|quai)\s+([A-Za-z0-9]+)\b/i); if (voieTextMatch) { platform = voieTextMatch[1].trim(); } } // 5. Détection de suppression const isCanceled = /supprim|annul|gelöscht|cancelled|interrompu/i.test(messageText) || s.includes('true'); if (platform) { for (const tNum of trainNums) { if (uic) { platformsMap.set(`${tNum}_${uic}`, platform); platformsMap.set(`${uic}_${tNum}`, platform); } if (uic7) { platformsMap.set(`${tNum}_${uic7}`, platform); platformsMap.set(`${uic7}_${tNum}`, platform); } if (stopNameClean) { platformsMap.set(`${tNum}_${stopNameClean}`, platform); } } } const info = { platform: platform, delayMinutes: 0, isCanceled: isCanceled, cause: summary || desc || '' }; for (const tNum of trainNums) { if (uic) updatesMap.set(`${tNum}_${uic}`, info); updatesMap.set(tNum, info); } if (messageText) { alertsList.push({ header: summary, text: desc || summary }); } } } situationExchangeCache = { timestamp: now, platforms: platformsMap, updates: updatesMap, alerts: alertsList }; } catch (err) { console.warn('[sncfApi] Notice: SIRI-Lite Situation Exchange error/timeout:', err); } return situationExchangeCache; } /** * Récupère les quais en temps réel depuis l'API OpenData SNCF (Navitia + OpenDataSoft) */ async function fetchOpenDataSncfPlatforms(uic, mode = 'departs') { if (!uic) return new Map(); const cacheKey = `${uic}_${mode}`; const now = Date.now(); if (openDataPlatformsCache.has(cacheKey)) { const cached = openDataPlatformsCache.get(cacheKey); if (now - cached.timestamp < 30000) { return cached.platforms; } } const platformsMap = new Map(); try { const authHeader = NAVITIA_TOKEN.startsWith('Basic') ? NAVITIA_TOKEN : 'Basic ' + btoa(NAVITIA_TOKEN + ':'); const uicClean = String(uic).replace(/\D/g, ''); const isArrival = mode === 'arrivees'; const navitiaPath = isArrival ? 'arrivals' : 'departures'; const odsDataset = isArrival ? 'prochaines-arrivees-des-trains-en-gare-temps-reel' : 'prochains-departs-des-trains-en-gare-temps-reel'; const navitiaEndpoints = [ `https://api.sncf.com/v1/coverage/sncf/stop_areas/stop_area:OCE:SA:${uicClean}/${navitiaPath}?count=100`, `https://api.sncf.com/v1/coverage/sncf/stop_areas/stop_area:SNCF:${uicClean}/${navitiaPath}?count=100`, `https://api.sncf.com/v1/coverage/sncf/stop_points/stop_point:OCE:SP:${uicClean}/${navitiaPath}?count=100`, `https://api.navitia.io/v1/coverage/sncf/stop_areas/stop_area:OCE:SA:${uicClean}/${navitiaPath}?count=100`, `https://api.navitia.io/v1/coverage/sncf/stop_areas/stop_area:${uicClean}/${navitiaPath}?count=100` ]; const [navitiaRes, odsV2Res, odsV1Res] = await Promise.allSettled([ fetchWithTimeout(navitiaEndpoints[0], { headers: { 'Authorization': authHeader } }, 5000) .then(async res => { if (res && res.ok) return res; return fetchWithTimeout(navitiaEndpoints[1], { headers: { 'Authorization': authHeader } }, 5000); }) .then(async res => { if (res && res.ok) return res; return fetchWithTimeout(navitiaEndpoints[3], { headers: { 'Authorization': NAVITIA_TOKEN } }, 5000); }), fetchWithTimeout(`https://ressources.data.sncf.com/api/explore/v2.1/catalog/datasets/${odsDataset}/records?limit=100&where=search(code_uic%2C%22${uicClean}%22)`, {}, 5000), fetchWithTimeout(`https://data.sncf.com/api/records/1.0/search/?dataset=${odsDataset}&q=${uicClean}&rows=100`, {}, 5000) ]); // 1. Décodage Navitia (API OpenData SNCF temps réel) if (navitiaRes.status === 'fulfilled' && navitiaRes.value && navitiaRes.value.ok) { const data = await navitiaRes.value.json(); const list = (isArrival ? data.arrivals : data.departures) || []; for (const item of list) { const info = item.display_informations || {}; const trainNum = cleanNum(info.headsign || info.trip_short_name || info.commercial_mode || info.code || ''); const rawNum = String(info.headsign || info.trip_short_name || info.code || '').replace(/\D/g, ''); const stopPoint = item.stop_point || (item.stop_date_time && item.stop_date_time.stop_point) || {}; let platform = ( stopPoint.platform_code || info.platform || '' ).toString().trim(); if (!platform && stopPoint.label) { const m = stopPoint.label.match(/(?:voie|quai|track|platform)\s*([a-z0-9]+)/i); if (m) platform = m[1]; } platform = platform.replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); const rawDt = (item.stop_date_time && (item.stop_date_time.departure_date_time || item.stop_date_time.arrival_date_time || item.stop_date_time.base_departure_date_time || item.stop_date_time.base_arrival_date_time)) || ''; let timeHM = ''; if (rawDt.includes('T')) { const tPart = rawDt.split('T')[1]; if (tPart && tPart.length >= 4) { timeHM = `${tPart.slice(0, 2)}:${tPart.slice(2, 4)}`; } } const destClean = info.direction ? formatStationName(info.direction).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; if (platform) { if (trainNum && uicClean) { platformsMap.set(`${trainNum}_${uicClean}`, platform); platformsMap.set(`${uicClean}_${trainNum}`, platform); } if (rawNum && uicClean) { platformsMap.set(`${rawNum}_${uicClean}`, platform); platformsMap.set(`${uicClean}_${rawNum}`, platform); } if (trainNum && destClean) { platformsMap.set(`${trainNum}_${destClean}`, platform); } } } } // 2. Décodage OpenDataSoft SNCF v2.1 if (odsV2Res.status === 'fulfilled' && odsV2Res.value && odsV2Res.value.ok) { const data = await odsV2Res.value.json(); const records = data.results || data.records || []; for (const r of records) { const fields = r.fields || r; const trainNum = cleanNum(fields.num || fields.numero || fields.train || fields.num_train || ''); const rawNum = String(fields.num || fields.numero || fields.train || fields.num_train || '').replace(/\D/g, ''); const platform = String(fields.voie || fields.quai || fields.platform || fields.track || fields.voie_quai || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); const destClean = fields.destination || fields.orig ? formatStationName(fields.destination || fields.orig).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; if (platform) { if (trainNum && uicClean) { platformsMap.set(`${trainNum}_${uicClean}`, platform); platformsMap.set(`${uicClean}_${trainNum}`, platform); } if (rawNum && uicClean) { platformsMap.set(`${rawNum}_${uicClean}`, platform); platformsMap.set(`${uicClean}_${rawNum}`, platform); } if (trainNum && destClean) { platformsMap.set(`${trainNum}_${destClean}`, platform); } } } } // 3. Décodage OpenDataSoft SNCF v1.0 if (odsV1Res.status === 'fulfilled' && odsV1Res.value && odsV1Res.value.ok) { const data = await odsV1Res.value.json(); const records = data.records || []; for (const r of records) { const fields = r.fields || {}; const trainNum = cleanNum(fields.num || fields.numero || fields.train || fields.num_train || ''); const rawNum = String(fields.num || fields.numero || fields.train || fields.num_train || '').replace(/\D/g, ''); const platform = String(fields.voie || fields.quai || fields.platform || fields.track || fields.voie_quai || '').replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); const destClean = fields.destination || fields.orig ? formatStationName(fields.destination || fields.orig).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; if (platform) { if (trainNum && uicClean) { platformsMap.set(`${trainNum}_${uicClean}`, platform); platformsMap.set(`${uicClean}_${trainNum}`, platform); } if (rawNum && uicClean) { platformsMap.set(`${rawNum}_${uicClean}`, platform); platformsMap.set(`${uicClean}_${rawNum}`, platform); } if (trainNum && destClean) { platformsMap.set(`${trainNum}_${destClean}`, platform); } } } } } catch (err) { console.warn('[sncfApi] Notice: OpenData SNCF platform fetch error:', err); } openDataPlatformsCache.set(cacheKey, { timestamp: now, platforms: platformsMap }); return platformsMap; } /** * 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([ fetchWithTimeout(SNCF_GTFS_RT_TRIP_UPDATES, {}, 6000), fetchWithTimeout(SNCF_GTFS_RT_SERVICE_ALERTS, {}, 6000) ]); // 1. Décodage Trip Updates (Retards, Suppressions) if (rtRes.status === 'fulfilled' && rtRes.value && 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 && 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 publicUrl = (typeof process !== 'undefined' && process.env && process.env.PUBLIC_URL) ? process.env.PUBLIC_URL : ''; const stationUrl = `${publicUrl}/data/stations/${uic}.json`; // Chargement parallèle tolérant aux pannes réseau / CORS (OpenData SNCF, SIRI-Lite Estimated Timetable, SIRI-Lite Situation Exchange, GTFS-RT) const [jsonRes, rtData, siriData, sxData, openDataPlatforms] = await Promise.all([ fetch(stationUrl).catch(err => { console.warn(`[sncfApi] Notice: fetch station ${uic} error:`, err); return null; }), fetchGtfsRealtimeData().catch(err => { console.warn('[sncfApi] Notice: GTFS-RT error:', err); return { updates: new Map(), alerts: [] }; }), fetchSiriLiteData().catch(err => { console.warn('[sncfApi] Notice: SIRI-Lite Timetable error:', err); return { platforms: new Map(), updates: new Map() }; }), fetchSituationExchangeData().catch(err => { console.warn('[sncfApi] Notice: SIRI-Lite Situation Exchange error:', err); return { platforms: new Map(), updates: new Map(), alerts: [] }; }), fetchOpenDataSncfPlatforms(uic, mode).catch(err => { console.warn('[sncfApi] Notice: OpenData SNCF platform error:', err); return new Map(); }) ]); if (!jsonRes || !jsonRes.ok) { console.warn(`[sncfApi] Fichier de gare introuvable : ${stationUrl} (statut : ${jsonRes ? jsonRes.status : 'réseau bloqué'})`); return null; } const data = await jsonRes.json(); const gareRef = findGtfsGare(uic); const targetStationNom = (gareRef && gareRef.nom) || (data && data.nom) || ''; 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')}`; const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); const tomorrowDay = tomorrow.getDay(); const tomorrowYYYYMMDD = `${tomorrow.getFullYear()}${String(tomorrow.getMonth() + 1).padStart(2, '0')}${String(tomorrow.getDate()).padStart(2, '0')}`; // Helper de vérification des règles de circulation calendaires const circulatesOnDate = (t, targetDay, targetYYYYMMDD, targetDate) => { const pad = (n) => String(n).padStart(2, '0'); const targetDateHyphen = `${targetDate.getFullYear()}-${pad(targetDate.getMonth() + 1)}-${pad(targetDate.getDate())}`; // 1. Base GTFS : si t.dates existe (tableau précis de dates YYYYMMDD), c'est la source de vérité absolue if (Array.isArray(t.dates) && t.dates.length > 0) { return t.dates.includes(targetYYYYMMDD) || t.dates.includes(targetDateHyphen); } // 2. Base Supabase : jours personnalisés const customDaysRaw = t.jours_personnalises; if (customDaysRaw) { let customDays = []; if (Array.isArray(customDaysRaw)) { customDays = customDaysRaw; } else if (typeof customDaysRaw === 'string') { try { customDays = JSON.parse(customDaysRaw); } catch (_) { customDays = []; } } if (customDays.includes(targetDateHyphen)) return true; } // 3. Base Supabase : circulation standard par jour de semaine const daysMap = ['circule_dimanche', 'circule_lundi', 'circule_mardi', 'circule_mercredi', 'circule_jeudi', 'circule_vendredi', 'circule_samedi']; const colName = daysMap[targetDay]; if (typeof t[colName] !== 'undefined') { const dd = t.date_debut ? String(t.date_debut).slice(0, 10) : null; const df = t.date_fin ? String(t.date_fin).slice(0, 10) : null; if (dd && targetDateHyphen < dd) return false; if (df && targetDateHyphen > df) return false; return Boolean(t[colName]); } // 4. Jours GTFS (fallback si t.dates n'était pas fourni) if (Array.isArray(t.days) && t.days.length > 0) { if (!t.days.includes(targetDay)) return false; if (t.start && targetYYYYMMDD < String(t.start).replace(/-/g, '')) return false; if (t.end && targetYYYYMMDD > String(t.end).replace(/-/g, '')) return false; return true; } // 5. Date unique fixée (date_depart, date, etc.) const singleDate = t.date_depart || t.date || t.service_date; if (singleDate) { const sStr = String(singleDate).slice(0, 10); return sStr === targetDateHyphen || sStr.replace(/-/g, '') === targetYYYYMMDD; } return true; }; // Helper de résolution de quai en temps réel (Estimated Timetable + Situation Exchange + OpenData) const lookupPlatform = (trainNum, tripId, stationUic, stationName, rawTimeStr = '', destName = '') => { const cNum = cleanNum(trainNum); const rawNum = String(trainNum || '').replace(/\D/g, ''); const cName = stationName ? formatStationName(stationName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; const cDest = destName ? formatStationName(destName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : ''; const uicClean = String(stationUic || '').replace(/\D/g, ''); const uic7 = (uicClean.length === 8 && uicClean.startsWith('87')) ? uicClean.slice(2) : ''; return ( // 1. SIRI-Lite Estimated Timetable (60 prochaines min - Priorité 1) (cNum && uicClean && siriData.platforms.get(`${cNum}_${uicClean}`)) || (rawNum && uicClean && siriData.platforms.get(`${rawNum}_${uicClean}`)) || (uicClean && cNum && siriData.platforms.get(`${uicClean}_${cNum}`)) || (cNum && uic7 && siriData.platforms.get(`${cNum}_${uic7}`)) || (uic7 && cNum && siriData.platforms.get(`${uic7}_${cNum}`)) || (cNum && cName && siriData.platforms.get(`${cNum}_${cName}`)) || (rawNum && cName && siriData.platforms.get(`${rawNum}_${cName}`)) || (tripId && uicClean && siriData.platforms.get(`${tripId}_${uicClean}`)) || (tripId && cName && siriData.platforms.get(`${tripId}_${cName}`)) || // 2. SIRI-Lite Situation Exchange (Circulations à venir / Changements de quai) (cNum && uicClean && sxData.platforms.get(`${cNum}_${uicClean}`)) || (rawNum && uicClean && sxData.platforms.get(`${rawNum}_${uicClean}`)) || (uicClean && cNum && sxData.platforms.get(`${uicClean}_${cNum}`)) || (cNum && cName && sxData.platforms.get(`${cNum}_${cName}`)) || (rawNum && cName && sxData.platforms.get(`${rawNum}_${cName}`)) || // 3. OpenData SNCF direct (cNum && uicClean && openDataPlatforms.get(`${cNum}_${uicClean}`)) || (rawNum && uicClean && openDataPlatforms.get(`${rawNum}_${uicClean}`)) || (cNum && cName && openDataPlatforms.get(`${cNum}_${cName}`)) || (cNum && cDest && openDataPlatforms.get(`${cNum}_${cDest}`)) || '' ); }; // Helper d'enrichissement d'un train const mapTrain = (t, idx, isTomorrow = false) => { const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr); const timeHM = parseTimeHM(rawTime) || 0; // Recherche de mise à jour GTFS-RT, SIRI-Lite Timetable ou SIRI-Lite Situation Exchange const rt = !isTomorrow ? (rtData.updates.get(t.trip_id) || (t.num ? rtData.updates.get(t.num) : null) || (cleanNum(t.num) ? rtData.updates.get(cleanNum(t.num)) : null)) : null; const siri = !isTomorrow ? (siriData.updates.get(t.num) || (cleanNum(t.num) ? siriData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? siriData.updates.get(t.trip_id) : null)) : null; const sx = !isTomorrow ? (sxData.updates.get(t.num) || (cleanNum(t.num) ? sxData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? sxData.updates.get(t.trip_id) : null)) : null; const isCanceled = (rt && rt.isCanceled) || (siri && siri.isCanceled) || (sx && sx.isCanceled) || false; const delayMinutes = (rt && rt.delayMinutes > 0) ? rt.delayMinutes : (siri && siri.delayMinutes > 0 ? siri.delayMinutes : 0); const currentStationName = targetStationNom || (data && data.nom) || (mode === 'arrivees' ? t.dest : t.orig); const destinationStation = mode === 'arrivees' ? t.orig : t.dest; const isAutocar = ( (t.type && (t.type.toUpperCase().includes('AUTOCAR') || t.type.toUpperCase().includes('CAR') || t.type.toUpperCase() === 'BUS')) || (t.type_train && (t.type_train.toUpperCase().includes('AUTOCAR') || t.type_train.toUpperCase().includes('CAR') || t.type_train.toUpperCase() === 'BUS')) || (t.op && (t.op.toUpperCase().includes('AUTOCAR') || t.op.toUpperCase().includes('CAR') || t.op.toUpperCase() === 'BUS')) || Boolean(t.is_substitution) || isCarNumber(t.num || t.numero_train || t.numero) ); let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName, rawTime, destinationStation); if (!realVoie && (t.quai || t.quai_reel || t.platform || t.voie)) { realVoie = String(t.quai || t.quai_reel || t.platform || t.voie).replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); } if (!realVoie) { realVoie = isAutocar ? 'AUTOCAR' : resolveNominalTrack(currentStationName, t.num, uic); } let statusLabel = "à l'heure"; let etat = 'a_l_heure'; if (isCanceled) { statusLabel = 'supprimé'; etat = 'supprime'; } else if (delayMinutes > 0) { statusLabel = `retardé +${delayMinutes}min.`; etat = 'retarde'; } let cause = (sx && sx.cause) || ''; if (!isTomorrow && delayMinutes > 0 && !cause) { if (rtData.alerts.length > 0) { cause = rtData.alerts[idx % rtData.alerts.length].text; } else if (sxData.alerts.length > 0) { cause = sxData.alerts[idx % sxData.alerts.length].text; } } const formattedStops = (t.stops || []).map((st) => { const stopUic = st.uic || ''; let stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom, st.heure, destinationStation); if (!stopVoie && (st.quai || st.quai_reel || st.voie || st.platform)) { stopVoie = String(st.quai || st.quai_reel || st.voie || st.platform).replace(/^(?:voie|quai|track|platform)\s*/i, '').trim(); } if (!stopVoie) { stopVoie = isAutocar ? 'AUTOCAR' : resolveNominalTrack(st.nom, t.num); } return { uic: stopUic, nom: formatStationName(st.nom), heure: formatTimeHM(st.heure), quai: stopVoie, quai_reel: stopVoie, voie: stopVoie, platform: stopVoie }; }); return { id: `gtfs-${isTomorrow ? 'demain-' : ''}${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'), _isTomorrow: isTomorrow, _displayTime: formatTimeHM(rawTime), _displayPlatform: realVoie, _depHM: timeHM, _arrHM: timeHM, _rawTime: rawTime, _status: { label: statusLabel, detail: '', delayMinutes: delayMinutes || null, causeDetails: cause || null } }; }; // 1. Filtrer les trains circulant aujourd'hui let rawToday = rawList.filter(t => circulatesOnDate(t, currentDay, currentYYYYMMDD, now)); if (rawToday.length === 0) { // Fallback si pas de calendrier GTFS explicite rawToday = rawList.filter(t => Array.isArray(t.days) && t.days.includes(currentDay)); if (rawToday.length === 0) rawToday = rawList; } const enrichedToday = rawToday.map((t, idx) => mapTrain(t, idx, false)); // Filtrer les trains passés : "Une fois que l'heure de départ / Arrivée est dépassée, alors ne plus l'afficher" const remainingToday = enrichedToday .filter(t => { const trainHM = (mode === 'arrivees' ? t._arrHM : t._depHM) || 0; const delay = t.retard_minutes || 0; return (trainHM + delay) >= currentHM; }) .sort((a, b) => { const aHM = (mode === 'arrivees' ? a._arrHM : a._depHM) || 0; const bHM = (mode === 'arrivees' ? b._arrHM : b._depHM) || 0; return aHM - bHM; }); // 2. Filtrer les trains circulant demain let rawTomorrow = rawList.filter(t => circulatesOnDate(t, tomorrowDay, tomorrowYYYYMMDD, tomorrow)); if (rawTomorrow.length === 0) { rawTomorrow = rawList.filter(t => Array.isArray(t.days) && t.days.includes(tomorrowDay)); } const enrichedTomorrow = rawTomorrow .map((t, idx) => mapTrain(t, idx, true)) .sort((a, b) => { const aHM = (mode === 'arrivees' ? a._arrHM : a._depHM) || 0; const bHM = (mode === 'arrivees' ? b._arrHM : b._depHM) || 0; return aHM - bHM; }); // 3. Logique : s'il reste <= 5 horaires aujourd'hui, afficher ceux d'aujourd'hui + ceux du lendemain let resultList = []; if (remainingToday.length <= 5) { resultList = [...remainingToday, ...enrichedTomorrow]; } else { resultList = remainingToday; } if (resultList.length === 0) { resultList = (enrichedToday.length > 0 ? enrichedToday : rawList.map((t, idx) => mapTrain(t, idx, false))).sort((a, b) => { const aHM = (mode === 'arrivees' ? a._arrHM : a._depHM) || 0; const bHM = (mode === 'arrivees' ? b._arrHM : b._depHM) || 0; return aHM - bHM; }); } return resultList; } 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); } else { // Si UIC non trouvé directement, chercher par nom const match = findGtfsGare(gareName); if (match && match.uic) { finalTrains = await fetchGtfsStationTimetable(match.uic, mode); } } if (!finalTrains) { finalTrains = []; } // 3. Filtrage strict par quai si demandé if (quai) { const cleanP = (p) => String(p || '').toLowerCase().replace(/^(?:voie|quai|track|platform)\s*/i, '').replace(/\s+/g, '').trim(); const targetP = cleanP(quai); finalTrains = finalTrains.filter(t => { const q = cleanP(t.quai || t.voie || t.quai_reel || t._displayPlatform); return Boolean(q && q === targetP); }); } 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); }