-> REFONTE TOTALE DE TOUS LES AFFICHEURS CLASSIQUES + QUAIS + OPTION TGV FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT : -> ADAPTABILITÉ EN FONCTION DES SITUATIONS
1097 lines
45 KiB
JavaScript
1097 lines
45 KiB
JavaScript
// 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>([^<]+)<\/TrainNumberRef>/);
|
|
if (tnr) {
|
|
const c = cleanNum(tnr[1]);
|
|
if (c) nums.add(c);
|
|
}
|
|
|
|
// 2. VehicleJourneyName
|
|
const vjn = journeyXml.match(/<VehicleJourneyName>([^<]+)<\/VehicleJourneyName>/);
|
|
if (vjn) {
|
|
const c = cleanNum(vjn[1]);
|
|
if (c) nums.add(c);
|
|
}
|
|
|
|
// 3. PublishedLineName if numeric
|
|
const pln = journeyXml.match(/<PublishedLineName>([^<]+)<\/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('<EstimatedVehicleJourney>');
|
|
|
|
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>([^<]+)<\/DatedVehicleJourneyRef>/) || j.match(/<VehicleJourneyRef>([^<]+)<\/VehicleJourneyRef>/) || j.match(/<FramedVehicleJourneyRef>[\s\S]*?<DatedVehicleJourneyRef>([^<]+)<\/DatedVehicleJourneyRef>/);
|
|
const journeyRef = journeyRefMatch ? journeyRefMatch[1].trim() : '';
|
|
|
|
const isCanceledJourney = j.includes('<Cancellation>true</Cancellation>') || j.includes('<JourneyStatus>cancelled</JourneyStatus>');
|
|
|
|
// 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>([^<]+)<\/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>([^<]+)<\/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>([^<]+)<\/QuayRef>/);
|
|
|
|
let rawPlatform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : (quayMatch ? quayMatch[1] : ''))).trim();
|
|
const platform = rawPlatform.replace(/^(?:voie|quai|track|platform)\s*/i, '').trim();
|
|
|
|
// Extraction des heures et calcul du retard
|
|
const expDepMatch = call.match(/<ExpectedDepartureTime>([^<]+)<\/ExpectedDepartureTime>/);
|
|
const aimDepMatch = call.match(/<AimedDepartureTime>([^<]+)<\/AimedDepartureTime>/);
|
|
const expArrMatch = call.match(/<ExpectedArrivalTime>([^<]+)<\/ExpectedArrivalTime>/);
|
|
const aimArrMatch = call.match(/<AimedArrivalTime>([^<]+)<\/AimedArrivalTime>/);
|
|
|
|
let delayMin = 0;
|
|
const expTimeStr = (expDepMatch && expDepMatch[1]) || (expArrMatch && expArrMatch[1]);
|
|
const aimTimeStr = (aimDepMatch && aimDepMatch[1]) || (aimArrMatch && aimArrMatch[1]);
|
|
|
|
if (expTimeStr && aimTimeStr) {
|
|
const expD = new Date(expTimeStr).getTime();
|
|
const aimD = new Date(aimTimeStr).getTime();
|
|
if (!isNaN(expD) && !isNaN(aimD)) {
|
|
delayMin = Math.max(0, Math.round((expD - aimD) / 60000));
|
|
}
|
|
}
|
|
|
|
const isStopCancelled = isCanceledJourney || call.includes('<DepartureStatus>cancelled</DepartureStatus>') || call.includes('<ArrivalStatus>cancelled</ArrivalStatus>') || call.includes('<Cancellation>true</Cancellation>');
|
|
|
|
if (platform) {
|
|
for (const tNum of trainNums) {
|
|
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('<PtSituationElement>');
|
|
|
|
for (let i = 1; i < situations.length; i++) {
|
|
const s = situations[i];
|
|
|
|
// 1. Extraction exhaustive du train et de la mission
|
|
const trainNums = extractAllTrainNumbers(s);
|
|
const trainNumMatch = s.match(/<TrainNumberRef>([^<]+)<\/TrainNumberRef>/) || s.match(/<VehicleJourneyRef>([^<]+)<\/VehicleJourneyRef>/) || s.match(/<PublishedLineName>([^<]+)<\/PublishedLineName>/);
|
|
const rawTrainNum = trainNumMatch ? trainNumMatch[1].trim() : '';
|
|
const primaryTrainNum = cleanNum(rawTrainNum);
|
|
if (primaryTrainNum && !trainNums.includes(primaryTrainNum)) {
|
|
trainNums.push(primaryTrainNum);
|
|
}
|
|
|
|
// 2. Extraction de l'arrêt impacté
|
|
const stopRefMatch = s.match(/<StopPointRef>([^<]+)<\/StopPointRef>/);
|
|
let uic = '';
|
|
let uic7 = '';
|
|
if (stopRefMatch) {
|
|
const rawRef = stopRefMatch[1];
|
|
const uicDigits = rawRef.match(/\d{7,8}/);
|
|
uic = uicDigits ? uicDigits[0] : rawRef.replace(/\D/g, '');
|
|
if (uic.length === 8 && uic.startsWith('87')) {
|
|
uic7 = uic.slice(2);
|
|
}
|
|
}
|
|
|
|
const stopNameMatch = s.match(/<StopPointName>([^<]+)<\/StopPointName>/);
|
|
const stopNameClean = stopNameMatch ? formatStationName(stopNameMatch[1]).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : '';
|
|
|
|
// 3. Extraction des messages en français
|
|
const summaryFrMatch = s.match(/<Summary[^>]*xml:lang="FR"[^>]*>([^<]+)<\/Summary>/i) || s.match(/<Summary[^>]*>([^<]+)<\/Summary>/i);
|
|
const summary = summaryFrMatch ? summaryFrMatch[1].trim() : '';
|
|
|
|
const descFrMatch = s.match(/<Description[^>]*xml:lang="FR"[^>]*>([^<]+)<\/Description>/i) || s.match(/<Description[^>]*>([^<]+)<\/Description>/i);
|
|
const desc = descFrMatch ? descFrMatch[1].trim() : '';
|
|
|
|
const messageText = desc || summary || '';
|
|
|
|
// 4. Détection de changement de quai / voie
|
|
const platTagMatch = s.match(/<(?:DeparturePlatformName|PlannedDeparturePlatformName|ArrivalPlatformName|PlannedArrivalPlatformName|QuayRef)>([^<]+)<\//i);
|
|
let platform = platTagMatch ? platTagMatch[1].replace(/^(?:voie|quai|track|platform)\s*/i, '').trim() : '';
|
|
|
|
if (!platform && messageText) {
|
|
const voieTextMatch = messageText.match(/\b(?:voie|quai)\s+([A-Za-z0-9]+)\b/i);
|
|
if (voieTextMatch) {
|
|
platform = voieTextMatch[1].trim();
|
|
}
|
|
}
|
|
|
|
// 5. Détection de suppression
|
|
const isCanceled = /supprim|annul|gelöscht|cancelled|interrompu/i.test(messageText) || s.includes('<Cancellation>true</Cancellation>');
|
|
|
|
if (platform) {
|
|
for (const tNum of trainNums) {
|
|
if (uic) {
|
|
platformsMap.set(`${tNum}_${uic}`, platform);
|
|
platformsMap.set(`${uic}_${tNum}`, platform);
|
|
}
|
|
if (uic7) {
|
|
platformsMap.set(`${tNum}_${uic7}`, platform);
|
|
platformsMap.set(`${uic7}_${tNum}`, platform);
|
|
}
|
|
if (stopNameClean) {
|
|
platformsMap.set(`${tNum}_${stopNameClean}`, platform);
|
|
}
|
|
}
|
|
}
|
|
|
|
const info = {
|
|
platform: platform,
|
|
delayMinutes: 0,
|
|
isCanceled: isCanceled,
|
|
cause: summary || desc || ''
|
|
};
|
|
|
|
for (const tNum of trainNums) {
|
|
if (uic) updatesMap.set(`${tNum}_${uic}`, info);
|
|
updatesMap.set(tNum, info);
|
|
}
|
|
|
|
if (messageText) {
|
|
alertsList.push({
|
|
header: summary,
|
|
text: desc || summary
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
|
|
|
|
|