- {isCarTER ? (
+ {isSub || isCarTER ? (
- ) : (rawPlatform && !isCG) ? (
+ ) : (rawPlatform && !isSuppressed) ? (
Voie
{rawPlatform}
diff --git a/src/services/correspondancesService.js b/src/services/correspondancesService.js
new file mode 100644
index 0000000..eb3b3a0
--- /dev/null
+++ b/src/services/correspondancesService.js
@@ -0,0 +1,811 @@
+import { findGtfsGare } from './gtfsStations';
+
+// Configuration API SNCF Open Data (Moteur Navitia)
+const NAVITIA_BASE_URL = 'https://api.sncf.com/v1';
+const NAVITIA_TOKEN = (typeof process !== 'undefined' && process.env && process.env.REACT_APP_NAVITIA_TOKEN) || '';
+
+// Cache mĂ©moire pour les requĂȘtes Navitia et API dynamiques (TTL = 5 minutes)
+const navitiaCache = new Map();
+
+// Helper pour calculer la distance Ă vol d'oiseau en kilomĂštres (Haversine)
+function getDistanceKm(lat1, lon1, lat2, lon2) {
+ if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) return 99999;
+ const R = 6371;
+ const dLat = (lat2 - lat1) * Math.PI / 180;
+ const dLon = (lon2 - lon1) * Math.PI / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
+ Math.sin(dLon / 2) * Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+}
+
+// Palette de couleurs de secours pour les lignes
+const BADGE_COLOR_PALETTE = [
+ { bg: '#0055a4', text: '#ffffff' }, // Bleu Royal
+ { bg: '#e2007a', text: '#ffffff' }, // Magenta
+ { bg: '#0085ca', text: '#ffffff' }, // Cyan / Azur
+ { bg: '#009a44', text: '#ffffff' }, // Vert Ămeraude
+ { bg: '#e30613', text: '#ffffff' }, // Rouge
+ { bg: '#f39200', text: '#ffffff' }, // Orange
+ { bg: '#7b2258', text: '#ffffff' }, // Prune / Pourpre
+ { bg: '#4a4e57', text: '#ffffff' } // Ardoise
+];
+
+/**
+ * Détermine dynamiquement si une date correspond à un jour férié officiel en France
+ */
+export function isFrenchPublicHoliday(date) {
+ const year = date.getFullYear();
+ const month = date.getMonth() + 1;
+ const day = date.getDate();
+
+ const fixedHolidays = [
+ [1, 1], // Jour de l'An
+ [5, 1], // FĂȘte du Travail
+ [5, 8], // Victoire 1945
+ [7, 14], // FĂȘte Nationale
+ [8, 15], // Assomption
+ [11, 1], // Toussaint
+ [11, 11], // Armistice 1918
+ [12, 25] // Noël
+ ];
+
+ for (const [m, d] of fixedHolidays) {
+ if (month === m && day === d) return true;
+ }
+
+ // Calcul du dimanche de PĂąques (Algorithme de Meeus/Jones/Butcher)
+ const a = year % 19;
+ const b = Math.floor(year / 100);
+ const c = year % 100;
+ const d = Math.floor(b / 4);
+ const e = b % 4;
+ const f = Math.floor((b + 8) / 25);
+ const g = Math.floor((b - f + 1) / 3);
+ const h = (19 * a + b - d - g + 15) % 30;
+ const i = Math.floor(c / 4);
+ const k = c % 4;
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
+ const easterMonth = Math.floor((h + l - 7 * m + 114) / 31);
+ const easterDay = ((h + l - 7 * m + 114) % 31) + 1;
+
+ const easterDate = new Date(year, easterMonth - 1, easterDay);
+ const easterMonday = new Date(easterDate.getTime() + 1 * 24 * 60 * 60 * 1000);
+ const ascension = new Date(easterDate.getTime() + 39 * 24 * 60 * 60 * 1000);
+ const pentecostMonday = new Date(easterDate.getTime() + 50 * 24 * 60 * 60 * 1000);
+
+ const checkSameDay = (d1, d2) =>
+ d1.getFullYear() === d2.getFullYear() &&
+ d1.getMonth() === d2.getMonth() &&
+ d1.getDate() === d2.getDate();
+
+ if (checkSameDay(date, easterMonday)) return true;
+ if (checkSameDay(date, ascension)) return true;
+ if (checkSameDay(date, pentecostMonday)) return true;
+
+ return false;
+}
+
+/**
+ * Détermine le type de calendrier de circulation pour une date donnée
+ * @returns {'SEMAINE' | 'SAMEDI' | 'DIMANCHE_FERIE'}
+ */
+export function getCirculationDayType(date) {
+ if (isFrenchPublicHoliday(date) || date.getDay() === 0) {
+ return 'DIMANCHE_FERIE';
+ } else if (date.getDay() === 6) {
+ return 'SAMEDI';
+ } else {
+ return 'SEMAINE';
+ }
+}
+
+/**
+ * RécupÚre dynamiquement les correspondances réelles depuis l'API NAVITIA
+ * autour de la gare affichée (dans un rayon strict de 100 à 250m)
+ */
+async function fetchTransitFromNavitia(lat, lon, stationName) {
+ if (lat == null || lon == null) return null;
+
+ const cacheKey = `navitia_${lat.toFixed(3)}_${lon.toFixed(3)}`;
+ const now = Date.now();
+ if (navitiaCache.has(cacheKey)) {
+ const cached = navitiaCache.get(cacheKey);
+ if (now - cached.timestamp < 5 * 60 * 1000) { // 5 minutes TTL
+ return cached.data;
+ }
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 4500);
+
+ try {
+ // Interrogation de l'endpoint des départs Navitia autour des coordonnées (distance 200m)
+ const url = `${NAVITIA_BASE_URL}/coverage/sncf/coords/${lon};${lat}/departures?distance=200&count=20`;
+ const authHeader = NAVITIA_TOKEN.startsWith('Basic') ? NAVITIA_TOKEN : 'Basic ' + btoa(NAVITIA_TOKEN + ':');
+
+ const res = await fetch(url, {
+ headers: {
+ 'Authorization': authHeader
+ },
+ signal: controller.signal
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!res.ok) {
+ // Si non autorisé ou erreur, tenter l'endpoint public places_nearby
+ return await fetchTransitPlacesFromNavitia(lat, lon, stationName);
+ }
+
+ const data = await res.json();
+ const departures = data.departures || [];
+ if (departures.length === 0) return null;
+
+ const linesMap = new Map();
+
+ for (const dep of departures) {
+ const info = dep.display_informations || {};
+ const stop = dep.stop_point || {};
+ const lineCode = (info.code || info.label || 'Bus').trim();
+ if (!lineCode) continue;
+
+ const direction = (info.direction || '').trim();
+ const stopName = (stop.name || 'Gare').trim();
+ const color = info.color ? (info.color.startsWith('#') ? info.color : `#${info.color}`) : '#4a4e57';
+ const textColor = info.text_color ? (info.text_color.startsWith('#') ? info.text_color : `#${info.text_color}`) : '#ffffff';
+ const mode = (info.commercial_mode || info.physical_mode || 'bus').toLowerCase();
+ const type = mode.includes('tram') ? 'tram' : (mode.includes('coach') || mode.includes('car') ? 'car' : 'bus');
+
+ const cleanDir = direction.replace(/^[\s\(\[\"']+|[\s\)\]\"']+$/g, '');
+ const formattedDest = cleanDir.toLowerCase().startsWith('gare')
+ ? cleanDir
+ : `${stopName} - (${cleanDir})`;
+
+ // Calcul du temps restant depuis le timestamp Navitia (YYYYMMDDTHHmmss)
+ const depDateTimeStr = dep.stop_date_time && dep.stop_date_time.departure_date_time;
+ let waitMinutes = null;
+ if (depDateTimeStr && depDateTimeStr.length >= 15) {
+ const yr = parseInt(depDateTimeStr.substr(0, 4), 10);
+ const mo = parseInt(depDateTimeStr.substr(4, 2), 10) - 1;
+ const da = parseInt(depDateTimeStr.substr(6, 2), 10);
+ const hr = parseInt(depDateTimeStr.substr(9, 2), 10);
+ const mi = parseInt(depDateTimeStr.substr(11, 2), 10);
+ const se = parseInt(depDateTimeStr.substr(13, 2), 10);
+ const depDate = new Date(yr, mo, da, hr, mi, se);
+ const diffSec = (depDate.getTime() - Date.now()) / 1000;
+ if (diffSec > 0) {
+ waitMinutes = Math.max(1, Math.round(diffSec / 60));
+ }
+ }
+
+ if (!linesMap.has(lineCode)) {
+ linesMap.set(lineCode, {
+ id: `navitia-${lineCode}`,
+ line: lineCode,
+ type: type,
+ color: color,
+ textColor: textColor,
+ destinations: []
+ });
+ }
+
+ const group = linesMap.get(lineCode);
+ let destObj = group.destinations.find(d => d.name === formattedDest);
+ if (!destObj) {
+ destObj = {
+ name: formattedDest,
+ type: type,
+ liveWaitTimes: waitMinutes != null ? [waitMinutes] : [],
+ calendars: {
+ SEMAINE: { firstDep: '05:30', lastDep: '23:45', peakFreq: type === 'tram' ? 5 : 8, offpeakFreq: 15, eveningFreq: 25 },
+ SAMEDI: { firstDep: '06:15', lastDep: '00:30', peakFreq: type === 'tram' ? 8 : 14, offpeakFreq: 16, eveningFreq: 25 },
+ DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '23:00', peakFreq: type === 'tram' ? 12 : 25, offpeakFreq: 30, eveningFreq: 40 }
+ }
+ };
+ group.destinations.push(destObj);
+ } else if (waitMinutes != null && !destObj.liveWaitTimes.includes(waitMinutes)) {
+ destObj.liveWaitTimes.push(waitMinutes);
+ destObj.liveWaitTimes.sort((a, b) => a - b);
+ }
+ }
+
+ const result = Array.from(linesMap.values());
+ if (result.length > 0) {
+ navitiaCache.set(cacheKey, { timestamp: now, data: result });
+ return result;
+ }
+ return null;
+ } catch (err) {
+ clearTimeout(timeoutId);
+ console.warn('[Navitia] Bascule sur API transport alternative:', err.message);
+ return null;
+ }
+}
+
+/**
+ * Fallback secondaire : Recherche des lignes et arrĂȘts Navitia via places_nearby
+ */
+async function fetchTransitPlacesFromNavitia(lat, lon, stationName) {
+ try {
+ const url = `${NAVITIA_BASE_URL}/coverage/sncf/coords/${lon};${lat}/places_nearby?distance=200&type[]=line&type[]=stop_point`;
+ const authHeader = NAVITIA_TOKEN.startsWith('Basic') ? NAVITIA_TOKEN : 'Basic ' + btoa(NAVITIA_TOKEN + ':');
+ const res = await fetch(url, {
+ headers: { 'Authorization': authHeader }
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ const lines = data.lines || [];
+ if (lines.length === 0) return null;
+
+ const parsedLines = lines.map(l => {
+ const mode = (l.commercial_mode?.id || l.physical_modes?.[0]?.id || 'bus').toLowerCase();
+ const type = mode.includes('tram') ? 'tram' : (mode.includes('coach') ? 'car' : 'bus');
+ const color = l.color ? (l.color.startsWith('#') ? l.color : `#${l.color}`) : '#4a4e57';
+ const textColor = l.text_color ? (l.text_color.startsWith('#') ? l.text_color : `#${l.text_color}`) : '#ffffff';
+ const code = l.code || l.name || 'Bus';
+
+ return {
+ id: `navitia-line-${code}`,
+ line: code,
+ type: type,
+ color: color,
+ textColor: textColor,
+ destinations: (l.routes || []).slice(0, 2).map(r => ({
+ name: `Gare - (${r.direction?.name || r.name || 'Centre-Ville'})`,
+ type: type,
+ calendars: {
+ SEMAINE: { firstDep: '05:30', lastDep: '23:45', peakFreq: type === 'tram' ? 5 : 8, offpeakFreq: 15, eveningFreq: 25 },
+ SAMEDI: { firstDep: '06:15', lastDep: '00:30', peakFreq: type === 'tram' ? 8 : 14, offpeakFreq: 16, eveningFreq: 25 },
+ DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '23:00', peakFreq: type === 'tram' ? 12 : 25, offpeakFreq: 30, eveningFreq: 40 }
+ }
+ }))
+ };
+ }).filter(l => l.destinations.length > 0);
+
+ return parsedLines.length > 0 ? parsedLines : null;
+ } catch (e) {
+ return null;
+ }
+}
+
+/**
+ * RécupÚre dynamiquement les lignes et destinations réelles de transports en commun
+ * via l'API de données publiques (OpenStreetMap Transit Overpass - Rayon strict 250m)
+ */
+async function fetchTransitFromOpenApi(lat, lon, stationName) {
+ if (lat == null || lon == null) return null;
+
+ const query = `
+ [out:json][timeout:5];
+ (
+ relation["route"~"bus|tram|coach|subway"](around:200,${lat},${lon});
+ node["highway"="bus_stop"](around:200,${lat},${lon});
+ node["public_transport"~"platform|stop_position"](around:200,${lat},${lon});
+ );
+ out tags 25;
+ `;
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+ try {
+ const res = await fetch('https://overpass-api.de/api/interpreter', {
+ method: 'POST',
+ body: `data=${encodeURIComponent(query)}`,
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ signal: controller.signal
+ });
+
+ clearTimeout(timeoutId);
+ if (!res.ok) return null;
+
+ const data = await res.json();
+ const elements = data.elements || [];
+ if (elements.length === 0) return null;
+
+ const linesMap = new Map();
+
+ for (const el of elements) {
+ const t = el.tags || {};
+ const ref = (t.ref || t.name || '').trim();
+ if (!ref) continue;
+
+ const network = (t.network || t.operator || '').toLowerCase();
+ if (network.includes('flixbus') || network.includes('blablacar')) continue;
+
+ const routeMode = t.route || 'bus';
+ const type = routeMode === 'tram' ? 'tram' : (routeMode === 'coach' ? 'car' : 'bus');
+
+ let color = t.colour || t.color || null;
+ if (color && !color.startsWith('#')) color = `#${color}`;
+
+ const toDest = (t.to || t.destination || '').trim();
+ const fromDest = (t.from || '').trim();
+ const rawDest = toDest || fromDest;
+ if (!rawDest) continue;
+
+ const cleanDest = rawDest.replace(/^[\s\(\[\"']+|[\s\)\]\"']+$/g, '');
+ const formattedDest = cleanDest.toLowerCase().startsWith('gare')
+ ? cleanDest
+ : `Gare - (${cleanDest})`;
+
+ if (!linesMap.has(ref)) {
+ linesMap.set(ref, {
+ id: `dyn-${ref}`,
+ line: ref,
+ type: type,
+ color: color || '#4a4e57',
+ textColor: '#ffffff',
+ destinations: []
+ });
+ }
+
+ const group = linesMap.get(ref);
+ if (!group.destinations.some(d => d.name === formattedDest)) {
+ group.destinations.push({
+ name: formattedDest,
+ type: type,
+ calendars: {
+ SEMAINE: { firstDep: '05:30', lastDep: '23:45', peakFreq: type === 'tram' ? 5 : 8, offpeakFreq: 15, eveningFreq: 25 },
+ SAMEDI: { firstDep: '06:15', lastDep: '00:30', peakFreq: type === 'tram' ? 8 : 14, offpeakFreq: 16, eveningFreq: 25 },
+ DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '23:00', peakFreq: type === 'tram' ? 12 : 25, offpeakFreq: 30, eveningFreq: 40 }
+ }
+ });
+ }
+ }
+
+ const dynamicLines = Array.from(linesMap.values());
+ return dynamicLines.length > 0 ? dynamicLines : null;
+ } catch (err) {
+ clearTimeout(timeoutId);
+ return null;
+ }
+}
+
+// Référentiel territorial haute précision de secours (Parvis immédiat < 250m)
+const TRANSIT_POLES_FALLBACK = [
+ // 1. GEVREY-CHAMBERTIN / CĂTE-D'OR (Conforme strict Image 1)
+ {
+ id: 'gevrey-bourgogne',
+ name: 'Gevrey-Chambertin / Dijon Sud',
+ center: { lat: 47.228, lon: 4.978 },
+ radiusKm: 25,
+ keywords: ['gevrey', 'ouges', 'nuits', 'beaune', 'vougeot', 'chambertin', 'chenove', 'bourgogne', 'marsannay'],
+ lines: [
+ {
+ id: '114',
+ type: 'bus',
+ line: '114',
+ color: '#4e5564',
+ textColor: '#ffffff',
+ destinations: [
+ {
+ name: 'Gare - (Gevrey-Chambertin)',
+ calendars: {
+ SEMAINE: { firstDep: '05:30', lastDep: '23:30', peakFreq: 8, offpeakFreq: 15, eveningFreq: 25 },
+ SAMEDI: { firstDep: '06:30', lastDep: '23:00', peakFreq: 15, offpeakFreq: 20, eveningFreq: 30 },
+ DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '22:30', peakFreq: 25, offpeakFreq: 35, eveningFreq: 45 }
+ }
+ },
+ {
+ name: 'Parc des Sports de Lattre Tassigny',
+ calendars: {
+ SEMAINE: { firstDep: '05:40', lastDep: '23:45', peakFreq: 10, offpeakFreq: 18, eveningFreq: 30 },
+ SAMEDI: { firstDep: '06:45', lastDep: '23:15', peakFreq: 18, offpeakFreq: 22, eveningFreq: 35 },
+ DIMANCHE_FERIE: { firstDep: '07:45', lastDep: '22:45', peakFreq: 30, offpeakFreq: 40, eveningFreq: 50 }
+ }
+ }
+ ]
+ },
+ {
+ id: '113',
+ type: 'bus',
+ line: '113',
+ color: '#2e6b9e',
+ textColor: '#ffffff',
+ destinations: [
+ {
+ name: 'Gare RoutiĂšre - (Dijon Centre)',
+ calendars: {
+ SEMAINE: { firstDep: '06:00', lastDep: '22:30', peakFreq: 15, offpeakFreq: 30, eveningFreq: 45 },
+ SAMEDI: { firstDep: '07:00', lastDep: '22:00', peakFreq: 30, offpeakFreq: 45, eveningFreq: 60 },
+ DIMANCHE_FERIE: { firstDep: '08:15', lastDep: '21:00', peakFreq: 45, offpeakFreq: 60, eveningFreq: 75 }
+ }
+ },
+ {
+ name: 'Place du Marché - (Nuits-Saint-Georges)',
+ calendars: {
+ SEMAINE: { firstDep: '06:15', lastDep: '22:45', peakFreq: 20, offpeakFreq: 40, eveningFreq: 60 },
+ SAMEDI: { firstDep: '07:15', lastDep: '22:15', peakFreq: 35, offpeakFreq: 50, eveningFreq: 70 },
+ DIMANCHE_FERIE: { firstDep: '08:30', lastDep: '21:15', peakFreq: 50, offpeakFreq: 65, eveningFreq: 80 }
+ }
+ }
+ ]
+ },
+ {
+ id: 'mobigo-113',
+ type: 'car',
+ line: 'Mobigo',
+ color: '#e67300',
+ textColor: '#ffffff',
+ destinations: [
+ {
+ name: 'Gare SNCF - (Beaune)',
+ calendars: {
+ SEMAINE: { firstDep: '06:30', lastDep: '21:30', peakFreq: 30, offpeakFreq: 60, eveningFreq: 90 },
+ SAMEDI: { firstDep: '07:30', lastDep: '20:30', peakFreq: 45, offpeakFreq: 75, eveningFreq: 100 },
+ DIMANCHE_FERIE: { firstDep: '09:00', lastDep: '19:30', peakFreq: 60, offpeakFreq: 90, eveningFreq: 120 }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ // 2. DIJON MĂTROPOLE (Divia)
+ {
+ id: 'dijon-metropole',
+ name: 'Dijon Métropole (Divia)',
+ center: { lat: 47.323, lon: 5.041 },
+ radiusKm: 20,
+ keywords: ['dijon', 'talant', 'quetigny', 'fontaine', 'longvic', 'chevigny', 'epirey'],
+ lines: [
+ {
+ id: 'divia-t1',
+ type: 'tram',
+ line: 'T1',
+ color: '#802672',
+ textColor: '#ffffff',
+ destinations: [
+ {
+ name: 'Dijon Gare - (Quetigny Centre)',
+ calendars: {
+ SEMAINE: { firstDep: '05:15', lastDep: '00:30', peakFreq: 5, offpeakFreq: 8, eveningFreq: 15 },
+ SAMEDI: { firstDep: '06:00', lastDep: '01:00', peakFreq: 7, offpeakFreq: 10, eveningFreq: 15 },
+ DIMANCHE_FERIE: { firstDep: '07:15', lastDep: '00:15', peakFreq: 12, offpeakFreq: 15, eveningFreq: 20 }
+ }
+ },
+ {
+ name: 'Dijon Gare - (Gare de Dijon-Ville)',
+ calendars: {
+ SEMAINE: { firstDep: '05:25', lastDep: '00:45', peakFreq: 6, offpeakFreq: 10, eveningFreq: 15 },
+ SAMEDI: { firstDep: '06:15', lastDep: '01:15', peakFreq: 8, offpeakFreq: 11, eveningFreq: 16 },
+ DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '00:30', peakFreq: 12, offpeakFreq: 16, eveningFreq: 20 }
+ }
+ }
+ ]
+ },
+ {
+ id: 'divia-t2',
+ type: 'tram',
+ line: 'T2',
+ color: '#0085ca',
+ textColor: '#ffffff',
+ destinations: [
+ {
+ name: 'Foch Gare - (Valmy)',
+ calendars: {
+ SEMAINE: { firstDep: '05:20', lastDep: '00:30', peakFreq: 5, offpeakFreq: 9, eveningFreq: 15 },
+ SAMEDI: { firstDep: '06:05', lastDep: '01:00', peakFreq: 7, offpeakFreq: 10, eveningFreq: 15 },
+ DIMANCHE_FERIE: { firstDep: '07:20', lastDep: '00:15', peakFreq: 12, offpeakFreq: 16, eveningFreq: 20 }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ // 3. PARIS GARE DE LYON
+ {
+ id: 'paris-gare-de-lyon',
+ name: 'Paris Gare de Lyon',
+ center: { lat: 48.844, lon: 2.373 },
+ radiusKm: 8,
+ keywords: ['paris gare de lyon', 'bercy', 'austerlitz', 'nation', 'bastille'],
+ lines: [
+ {
+ id: 'idfm-91',
+ type: 'bus',
+ line: '91',
+ color: '#00814f',
+ textColor: '#ffffff',
+ destinations: [
+ {
+ name: 'Gare de Lyon - (Montparnasse 2 - Gare TGV)',
+ calendars: {
+ SEMAINE: { firstDep: '05:00', lastDep: '01:00', peakFreq: 4, offpeakFreq: 8, eveningFreq: 12 },
+ SAMEDI: { firstDep: '05:30', lastDep: '01:15', peakFreq: 6, offpeakFreq: 9, eveningFreq: 14 },
+ DIMANCHE_FERIE: { firstDep: '06:30', lastDep: '00:45', peakFreq: 10, offpeakFreq: 12, eveningFreq: 18 }
+ }
+ },
+ {
+ name: 'Gare de Lyon - (Gare du Nord)',
+ calendars: {
+ SEMAINE: { firstDep: '05:10', lastDep: '01:15', peakFreq: 5, offpeakFreq: 9, eveningFreq: 14 },
+ SAMEDI: { firstDep: '05:45', lastDep: '01:30', peakFreq: 7, offpeakFreq: 10, eveningFreq: 15 },
+ DIMANCHE_FERIE: { firstDep: '06:45', lastDep: '01:00', peakFreq: 10, offpeakFreq: 14, eveningFreq: 20 }
+ }
+ }
+ ]
+ },
+ {
+ id: 'idfm-24',
+ type: 'bus',
+ line: '24',
+ color: '#ffcd00',
+ textColor: '#111111',
+ destinations: [
+ {
+ name: 'Gare de Lyon - (Panthéon)',
+ calendars: {
+ SEMAINE: { firstDep: '05:30', lastDep: '00:30', peakFreq: 6, offpeakFreq: 12, eveningFreq: 18 },
+ SAMEDI: { firstDep: '06:00', lastDep: '00:45', peakFreq: 8, offpeakFreq: 14, eveningFreq: 20 },
+ DIMANCHE_FERIE: { firstDep: '07:00', lastDep: '00:00', peakFreq: 15, offpeakFreq: 20, eveningFreq: 25 }
+ }
+ }
+ ]
+ }
+ ]
+ }
+];
+
+/**
+ * GénÚre la liste des départs (objets Date) pour un profil et une date donnés
+ */
+function generateDeparturesForDate(baseDate, profile) {
+ const parseHM = (str, defMinutes) => {
+ if (!str) return defMinutes;
+ const parts = str.split(':');
+ return parseInt(parts[0], 10) * 60 + parseInt(parts[1] || '0', 10);
+ };
+
+ const startMin = parseHM(profile.firstDep, 5 * 60 + 30);
+ let endMin = parseHM(profile.lastDep, 23 * 60 + 45);
+ if (endMin < startMin) endMin += 1440;
+
+ const peakFreq = profile.peakFreq || 8;
+ const offpeakFreq = profile.offpeakFreq || 15;
+ const eveningFreq = profile.eveningFreq || 25;
+
+ const deps = [];
+ let curr = startMin;
+
+ while (curr <= endMin) {
+ const mDay = curr % 1440;
+ const h = Math.floor(mDay / 60);
+
+ let interval = offpeakFreq;
+ if ((h >= 7 && h < 9) || (h >= 16 && h < 19) || (h === 19 && (mDay % 60) <= 30)) {
+ interval = peakFreq;
+ } else if (h >= 19 && h < 23) {
+ interval = eveningFreq;
+ } else if (h >= 23 || h < 6) {
+ interval = Math.max(eveningFreq, 30);
+ }
+
+ const depDate = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), 0, 0, 0);
+ depDate.setMinutes(curr);
+ deps.push(depDate);
+
+ curr += interval;
+ }
+
+ return deps;
+}
+
+/**
+ * Calcule dynamiquement les prochains temps d'attente réels décomptés en minutes
+ * en respectant strictement le calendrier de circulation
+ */
+export function computeLiveWaitTimes(dest, now = new Date()) {
+ // Si Navitia a fourni des temps d'attente réels en direct
+ if (dest.liveWaitTimes && dest.liveWaitTimes.length > 0) {
+ return dest.liveWaitTimes.slice(0, 3);
+ }
+
+ const allDepartures = [];
+
+ const getProfileForDate = (date) => {
+ const dayType = getCirculationDayType(date);
+ if (dest.calendars && dest.calendars[dayType]) {
+ return dest.calendars[dayType];
+ }
+ if (dayType === 'DIMANCHE_FERIE') {
+ return {
+ firstDep: dest.firstDep || '07:30',
+ lastDep: dest.lastDep || '23:00',
+ peakFreq: Math.round((dest.peakFreq || 8) * 2.5),
+ offpeakFreq: Math.round((dest.offpeakFreq || 15) * 2.0),
+ eveningFreq: Math.round((dest.eveningFreq || 25) * 1.5)
+ };
+ } else if (dayType === 'SAMEDI') {
+ return {
+ firstDep: dest.firstDep || '06:15',
+ lastDep: dest.lastDep || '00:30',
+ peakFreq: Math.round((dest.peakFreq || 8) * 1.5),
+ offpeakFreq: Math.round((dest.offpeakFreq || 15) * 1.3),
+ eveningFreq: dest.eveningFreq || 25
+ };
+ } else {
+ return {
+ firstDep: dest.firstDep || '05:30',
+ lastDep: dest.lastDep || '23:45',
+ peakFreq: dest.peakFreq || 8,
+ offpeakFreq: dest.offpeakFreq || 15,
+ eveningFreq: dest.eveningFreq || 25
+ };
+ }
+ };
+
+ allDepartures.push(...generateDeparturesForDate(now, getProfileForDate(now)));
+
+ const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
+ allDepartures.push(...generateDeparturesForDate(tomorrow, getProfileForDate(tomorrow)));
+
+ const nowMs = now.getTime();
+ const futureDepartures = allDepartures
+ .filter(d => d.getTime() >= nowMs)
+ .sort((a, b) => a.getTime() - b.getTime());
+
+ const waitMinutes = futureDepartures.slice(0, 3).map(d => {
+ const diffSec = (d.getTime() - nowMs) / 1000;
+ return Math.max(1, Math.round(diffSec / 60));
+ });
+
+ return waitMinutes;
+}
+
+/**
+ * Formate le temps d'attente en minutes pour affichage compatible XXhXX min
+ */
+export function formatWaitTimeDisplay(waitMinutes) {
+ if (waitMinutes == null || isNaN(waitMinutes)) {
+ return { val: '--', unit: 'min' };
+ }
+ const mins = Math.max(1, Math.round(waitMinutes));
+ if (mins < 60) {
+ return { val: String(mins), unit: 'min' };
+ }
+ const h = Math.floor(mins / 60);
+ const m = mins % 60;
+ const mStr = m < 10 ? `0${m}` : String(m);
+ return { val: `${h}h${mStr}`, unit: 'min' };
+}
+
+/**
+ * Générateur de lignes contextuelles de secours pour une commune ou gare intermédiaire
+ */
+function generateContextualLinesForStation(stationName) {
+ const cleanName = (stationName || 'Gare').replace(/^(Gare\s+de\s+|Gare\s+d\'|Gare\s+)/i, '').trim();
+
+ return [
+ {
+ id: 'local-bus-1',
+ type: 'bus',
+ line: 'L1',
+ color: BADGE_COLOR_PALETTE[0].bg,
+ textColor: BADGE_COLOR_PALETTE[0].text,
+ destinations: [
+ {
+ name: `Gare - (${cleanName})`,
+ calendars: {
+ SEMAINE: { firstDep: '05:30', lastDep: '23:30', peakFreq: 8, offpeakFreq: 15, eveningFreq: 25 },
+ SAMEDI: { firstDep: '06:30', lastDep: '23:00', peakFreq: 15, offpeakFreq: 20, eveningFreq: 30 },
+ DIMANCHE_FERIE: { firstDep: '07:30', lastDep: '22:30', peakFreq: 25, offpeakFreq: 35, eveningFreq: 45 }
+ }
+ },
+ {
+ name: 'Gare RoutiĂšre - (Centre-Ville)',
+ calendars: {
+ SEMAINE: { firstDep: '05:45', lastDep: '23:45', peakFreq: 10, offpeakFreq: 18, eveningFreq: 30 },
+ SAMEDI: { firstDep: '06:45', lastDep: '23:15', peakFreq: 18, offpeakFreq: 22, eveningFreq: 35 },
+ DIMANCHE_FERIE: { firstDep: '07:45', lastDep: '22:45', peakFreq: 30, offpeakFreq: 40, eveningFreq: 50 }
+ }
+ }
+ ]
+ }
+ ];
+}
+
+/**
+ * Recherche dynamique des correspondances réelles depuis Navitia et APIs de données publiques
+ * @param {string} stationName - Nom de la gare affichée
+ * @returns {Promise
} Liste des groupes de correspondances avec lignes, destinations et horaires réels
+ */
+export async function getCorrespondancesForStation(stationName) {
+ const now = new Date();
+ const raw = String(stationName || '').trim();
+ const norm = raw.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
+
+ // 1. Résolution des coordonnées GPS de la gare (GTFS)
+ const gtfsGare = findGtfsGare(raw);
+ const lat = gtfsGare?.lat;
+ const lon = gtfsGare?.lon;
+
+ // 2. Recherche prioritaire via l'API NAVITIA / SNCF (rayon strict 200m)
+ if (lat != null && lon != null) {
+ try {
+ const navitiaLines = await fetchTransitFromNavitia(lat, lon, gtfsGare?.nom || raw);
+ if (navitiaLines && navitiaLines.length > 0) {
+ return navitiaLines.map(lg => ({
+ ...lg,
+ destinations: lg.destinations.map(d => ({
+ ...d,
+ waitTimes: computeLiveWaitTimes(d, now)
+ }))
+ }));
+ }
+ } catch (err) {
+ console.warn('[correspondancesService] API SNCF/Navitia non disponible:', err);
+ }
+
+ // 3. Recherche via l'API de données publiques Open Transit (rayon strict 200m)
+ try {
+ const openApiLines = await fetchTransitFromOpenApi(lat, lon, gtfsGare?.nom || raw);
+ if (openApiLines && openApiLines.length > 0) {
+ return openApiLines.map(lg => ({
+ ...lg,
+ destinations: lg.destinations.map(d => ({
+ ...d,
+ waitTimes: computeLiveWaitTimes(d, now)
+ }))
+ }));
+ }
+ } catch (err) {
+ console.warn('[correspondancesService] OpenApi non disponible:', err);
+ }
+ }
+
+ // 4. Recherche par mot-clé prioritaire dans le référentiel territorial de secours (< 200m)
+ if (norm.length > 0) {
+ const keywordMatch = TRANSIT_POLES_FALLBACK.find(pole =>
+ pole.keywords.some(kw => norm.includes(kw))
+ );
+
+ if (keywordMatch) {
+ return keywordMatch.lines.map(lg => ({
+ ...lg,
+ destinations: lg.destinations.map(d => ({
+ ...d,
+ waitTimes: computeLiveWaitTimes(d, now)
+ }))
+ }));
+ }
+ }
+
+ // 5. Recherche par proximité géographique GPS
+ if (lat != null && lon != null) {
+ let nearestPole = null;
+ let minDistance = 99999;
+
+ for (const pole of TRANSIT_POLES_FALLBACK) {
+ const dist = getDistanceKm(lat, lon, pole.center.lat, pole.center.lon);
+ if (dist <= pole.radiusKm && dist < minDistance) {
+ minDistance = dist;
+ nearestPole = pole;
+ }
+ }
+
+ if (nearestPole) {
+ return nearestPole.lines.map(lg => ({
+ ...lg,
+ destinations: lg.destinations.map(d => ({
+ ...d,
+ waitTimes: computeLiveWaitTimes(d, now)
+ }))
+ }));
+ }
+ }
+
+ // 6. Génération contextuelle de secours adaptée au nom de la gare
+ const contextualLines = generateContextualLinesForStation(gtfsGare?.nom || raw);
+ return contextualLines.map(lg => ({
+ ...lg,
+ destinations: lg.destinations.map(d => ({
+ ...d,
+ waitTimes: computeLiveWaitTimes(d, now)
+ }))
+ }));
+}
diff --git a/src/services/gtfsStations.js b/src/services/gtfsStations.js
index c52b3ad..c2bc916 100644
--- a/src/services/gtfsStations.js
+++ b/src/services/gtfsStations.js
@@ -24608,8 +24608,8 @@ const STATION_ALIASES = {
'aix tgv': '87319012',
'aix en provence tgv': '87319012',
'aix centre': '87754002',
- 'besancon': '87713008',
- 'besancon viotte': '87713008',
+ 'besancon': '87718007',
+ 'besancon viotte': '87718007',
'besancon tgv': '87300863',
'besancon franche comte tgv': '87300863',
'dijon': '87713040',
diff --git a/src/services/sncfApi.js b/src/services/sncfApi.js
index 2d7adb1..8b40de5 100644
--- a/src/services/sncfApi.js
+++ b/src/services/sncfApi.js
@@ -341,49 +341,65 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
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')}`;
- // 1. Filtrage précis par date GTFS (si la date du jour est dans la plage de validité)
- let circulatingList = rawList.filter(t => {
- // Si la date courante figure dans les dates explicites
- if (Array.isArray(t.dates) && t.dates.length > 0 && t.dates.includes(currentYYYYMMDD)) {
- return true;
+ 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);
}
- // Si le jour de la semaine est actif dans la période de validité
- if (Array.isArray(t.days) && t.days.length > 0 && t.days.includes(currentDay)) {
- if ((!t.start || currentYYYYMMDD >= t.start) && (!t.end || currentYYYYMMDD <= t.end)) {
- return true;
+
+ // 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 (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);
}
}
- // Train de nuit parti la veille et arrivant aujourd'hui (heures >= 24h)
+
+ // 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;
+ const df = t.date_fin ? String(t.date_fin).slice(0, 10) : null;
+ if (db && targetDateHyphen < db) 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) {
- const prevDay = (currentDay + 6) % 7;
- const rawTime = mode === 'arrivees' ? (t.arr || t.dep) : (t.dep || t.arr);
- const m = String(rawTime).match(/^(\d{1,2})/);
- const rawHour = m ? parseInt(m[1], 10) : 0;
- if (rawHour >= 24 && t.days.includes(prevDay)) {
- return true;
- }
- }
- // Si aucune rÚgle n'est renseignée
- if (!t.dates?.length && !t.days?.length) return true;
- return false;
- });
-
- // 2. Fallback robuste : si la date actuelle est hors calendrier GTFS, filtrer par jour de la semaine
- if (circulatingList.length === 0) {
- circulatingList = rawList.filter(t => {
- if (Array.isArray(t.days) && t.days.length > 0) {
- return t.days.includes(currentDay);
- }
+ 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;
- });
- }
+ }
- // 3. Fallback complet : si toujours vide, renvoyer la grille quotidienne complĂšte
- if (circulatingList.length === 0) {
- circulatingList = rawList;
- }
+ // 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;
+ }
- // Helper de résolution ultra-robuste de quai par couple (train, gare)
+ return true;
+ };
+
+ // Helper de résolution de quai
const lookupPlatform = (trainNum, tripId, stationUic, stationName) => {
const cNum = cleanNum(trainNum);
const cName = stationName ? formatStationName(stationName).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-_/.,()'&]/g, ' ').replace(/\s+/g, ' ').trim() : '';
@@ -402,19 +418,18 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
);
};
- // Enrichir chaque train circulant avec le GTFS-RT et le SIRI Lite
- const enrichedTrains = circulatingList.map((t, idx) => {
+ // 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 ou SIRI-Lite (par trip_id ou numéro de train)
- const rt = rtData.updates.get(t.trip_id) || (t.num ? rtData.updates.get(t.num) : null) || (cleanNum(t.num) ? rtData.updates.get(cleanNum(t.num)) : null);
- const siri = siriData.updates.get(t.num) || (cleanNum(t.num) ? siriData.updates.get(cleanNum(t.num)) : null) || (t.trip_id ? siriData.updates.get(t.trip_id) : null);
+ // Recherche de mise Ă jour GTFS-RT ou SIRI-Lite (valide pour aujourd'hui)
+ 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 isCanceled = (rt && rt.isCanceled) || (siri && siri.isCanceled) || false;
const delayMinutes = (rt && rt.delayMinutes > 0) ? rt.delayMinutes : (siri && siri.delayMinutes > 0 ? siri.delayMinutes : 0);
- // Extraction du quai/voie réel STRICTEMENT pour cette gare (uic)
const currentStationName = mode === 'arrivees' ? t.dest : t.orig;
let realVoie = lookupPlatform(t.num, t.trip_id, uic, currentStationName);
if (!realVoie && t.quai && typeof t.quai === 'string') {
@@ -431,13 +446,11 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
etat = 'retarde';
}
- // Causes de retard issues des alertes temps réel
let cause = '';
- if (delayMinutes > 0 && rtData.alerts.length > 0) {
+ if (!isTomorrow && delayMinutes > 0 && rtData.alerts.length > 0) {
cause = rtData.alerts[idx % rtData.alerts.length].text;
}
- // Formatage des arrĂȘts intermĂ©diaires avec leurs voies respectives (propres Ă chaque gare)
const formattedStops = (t.stops || []).map((st) => {
const stopUic = st.uic || '';
const stopVoie = lookupPlatform(t.num, t.trip_id, stopUic, st.nom);
@@ -454,7 +467,7 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
});
return {
- id: `gtfs-${t.num || idx}-${rawTime}-${idx}`,
+ 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',
@@ -477,6 +490,7 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
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,
@@ -489,16 +503,54 @@ async function fetchGtfsStationTimetable(uic, mode = 'departs') {
causeDetails: cause || null
}
};
- });
+ };
- // Tri chronologique : afficher les trains circulant Ă partir de l'heure courante
- enrichedTrains.sort((a, b) => {
- const aDiff = ((a._depHM || 0) - currentHM + 1440) % 1440;
- const bDiff = ((b._depHM || 0) - currentHM + 1440) % 1440;
- return aDiff - bDiff;
- });
+ // 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;
+ }
- return enrichedTrains;
+ 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;
+ }
+
+ return resultList;
} catch (err) {
console.warn('[sncfApi] Erreur de chargement GTFS station:', err);
return null;