RELEASE V5.6.0 : Voici les modifications apportées :

-> REFONTE TOTALE DE TOUS LES AFFICHEURS CLASSIQUES + QUAIS + OPTION TGV

FONCTIONNALITÉS EN COURS DE DÉVELOPPEMENT :

-> ADAPTABILITÉ EN FONCTION DES SITUATIONS
This commit is contained in:
mathis committed 2026-08-31 17:32:58 +02:00
1 parent 9c6ac798f1
commit d8f8ceaa9f
18 files changed
+2324 -533

No files matched your search

+546 -85
View File
@@ -5,23 +5,81 @@ 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';
// Cache mémoire pour le flux GTFS-RT (TTL = 30s)
// 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 (TTL = 30s)
// 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 = 7000) {
async function fetchWithTimeout(url, options = {}, timeoutMs = 25000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
@@ -40,12 +98,53 @@ function cleanNum(n) {
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 les flux temps réel SIRI Lite SNCF (Quais réels et horaires estimés)
* 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 < 30000 && siriLiteCache.platforms.size > 0) {
if (now - siriLiteCache.timestamp < REALTIME_CACHE_TTL && siriLiteCache.platforms.size > 0) {
return siriLiteCache;
}
@@ -53,79 +152,112 @@ async function fetchSiriLiteData() {
const updatesMap = new Map();
try {
const res = await fetchWithTimeout(SNCF_SIRI_LITE_TIMETABLE, {}, 8000);
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];
const trainNumMatch = j.match(/<TrainNumberRef>([^<]+)<\/TrainNumberRef>/);
const rawTrainNum = trainNumMatch ? trainNumMatch[1].trim() : '';
const trainNum = cleanNum(rawTrainNum);
const journeyRefMatch = j.match(/<DatedVehicleJourneyRef>([^<]+)<\/DatedVehicleJourneyRef>/) || j.match(/<VehicleJourneyRef>([^<]+)<\/VehicleJourneyRef>/);
// 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 isCanceled = j.includes('<Cancellation>true</Cancellation>');
const isCanceledJourney = j.includes('<Cancellation>true</Cancellation>') || j.includes('<JourneyStatus>cancelled</JourneyStatus>');
// Extraire les arrêts et quais (RecordedCall et EstimatedCall)
const callMatches = j.match(/<(RecordedCall|EstimatedCall)>[\s\S]*?<\/\1>/g) || [];
// 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) {
const stopRefMatch = call.match(/<StopPointRef>[^:]*::(\d+)<\/StopPointRef>/) || call.match(/<StopPointRef>([^<]+)<\/StopPointRef>/);
const uicMatch = stopRefMatch ? (stopRefMatch[1].match(/\d{7,8}/) || [stopRefMatch[1]]) : null;
const uic = uicMatch ? uicMatch[0] : '';
// 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() : '';
const depPlatMatch = call.match(/<DeparturePlatformName>([^<]+)<\/DeparturePlatformName>/);
const arrPlatMatch = call.match(/<ArrivalPlatformName>([^<]+)<\/ArrivalPlatformName>/);
const platform = (depPlatMatch ? depPlatMatch[1] : (arrPlatMatch ? arrPlatMatch[1] : '')).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;
if (expDepMatch && aimDepMatch) {
const expD = new Date(expDepMatch[1]).getTime();
const aimD = new Date(aimDepMatch[1]).getTime();
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) {
if (uic) {
if (trainNum) {
platformsMap.set(`${trainNum}_${uic}`, platform);
platformsMap.set(`${uic}_${trainNum}`, platform);
for (const tNum of trainNums) {
if (uic) {
platformsMap.set(`${tNum}_${uic}`, platform);
platformsMap.set(`${uic}_${tNum}`, platform);
}
if (rawTrainNum) {
platformsMap.set(`${rawTrainNum}_${uic}`, platform);
platformsMap.set(`${uic}_${rawTrainNum}`, platform);
if (uic7) {
platformsMap.set(`${tNum}_${uic7}`, platform);
platformsMap.set(`${uic7}_${tNum}`, platform);
}
if (journeyRef) {
if (stopNameClean) {
platformsMap.set(`${tNum}_${stopNameClean}`, platform);
}
}
if (journeyRef) {
if (uic) {
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 (stopNameClean) {
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);
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);
}
}
}
@@ -137,12 +269,298 @@ async function fetchSiriLiteData() {
updates: updatesMap
};
} catch (err) {
console.warn('[sncfApi] Notice: SIRI-Lite refresh error/timeout:', 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
*/
@@ -311,8 +729,8 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
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
const [jsonRes, rtData, siriData] = await Promise.all([
// 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;
@@ -322,8 +740,16 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
return { updates: new Map(), alerts: [] };
}),
fetchSiriLiteData().catch(err => {
console.warn('[sncfApi] Notice: SIRI-Lite error:', 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();
})
]);
@@ -333,6 +759,8 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
}
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;
@@ -362,21 +790,18 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
if (Array.isArray(customDaysRaw)) {
customDays = customDaysRaw;
} else if (typeof customDaysRaw === 'string') {
try { customDays = JSON.parse(customDaysRaw); } catch (e) {}
}
if (Array.isArray(customDays) && customDays.length > 0) {
const match = customDays.find(item => item.date === targetDateHyphen || item.date === targetYYYYMMDD);
if (match) return Boolean(match.circule);
try { customDays = JSON.parse(customDaysRaw); } catch (_) { customDays = []; }
}
if (customDays.includes(targetDateHyphen)) return true;
}
// 3. Colonnes circule_lundi, circule_mardi...
const dayNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
const colName = `circule_${dayNames[targetDay]}`;
if (typeof t[colName] !== 'undefined' && t[colName] !== null) {
const db = t.date_debut ? String(t.date_debut).slice(0, 10) : null;
// 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 (db && targetDateHyphen < db) return false;
if (dd && targetDateHyphen < dd) return false;
if (df && targetDateHyphen > df) return false;
return Boolean(t[colName]);
}
@@ -399,21 +824,37 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
return true;
};
// Helper de résolution de quai
const lookupPlatform = (trainNum, tripId, stationUic, stationName) => {
// 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 (
(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}`)) ||
// 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}`)) ||
(trainNum && cName && siriData.platforms.get(`${trainNum}_${cName}`)) ||
(tripId && stationUic && siriData.platforms.get(`${tripId}_${stationUic}`)) ||
(stationUic && tripId && siriData.platforms.get(`${stationUic}_${tripId}`)) ||
(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}`)) ||
''
);
};
@@ -423,17 +864,30 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
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 (valide pour aujourd'hui)
// 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) || false;
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 = 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();
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";
@@ -446,14 +900,24 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
etat = 'retarde';
}
let cause = '';
if (!isTomorrow && delayMinutes > 0 && rtData.alerts.length > 0) {
cause = rtData.alerts[idx % rtData.alerts.length].text;
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 || '';
const stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom);
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,
@@ -580,13 +1044,10 @@ export async function getSncfTimetable({ gareName, mode = 'departs', quai = null
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) {
} else {
// Si UIC non trouvé directement, chercher par nom
const match = findGtfsGare(gareName);
if (match && match.uic) {
finalTrains = await fetchGtfsStationTimetable(match.uic, mode);
}
}